Sayuj63 commited on
Commit
34a5113
·
1 Parent(s): 1c7d425

Phase 3: server-side sub-agent infrastructure (spawn / budget / return)

Browse files

- New action types accepted by SecurityAuditAction + LLMJsonAction:
spawn_subagent and return_to_parent (Pydantic Literal extended)
- spawn_subagent validates scope (host|endpoint|cred) and target against the
dynamic _attack_surface; rejects spawn when another sub-agent is still active
or when target is unknown. Auto-admits a revealed-but-not-yet-discovered host
to discovered_hosts so sub-agent tools can immediately reach it.
- Sub-agent registry tracks: scope, target, budget (clamped 2..15),
steps_used, findings_at_spawn, parent_step, status.
- step() auto-decrements budget on every action while a sub-agent is active;
on budget exhaust, sub-agent is closed and outcome recorded as timeout.
- return_to_parent computes productivity (>=1 finding submitted while sub-agent
was active = productive) and emits delegation reward: +0.05 productive,
-0.05 unproductive. Outcome appended to _subagent_outcomes for the future
Delegation Score grader component.
- submit_finding tags findings with _spawn_id and _parent_step when a
sub-agent is active, so the grader can attribute findings to branches.
- _attack_surface initializes from VISIBLE scenario hosts only — hidden hosts
enter the surface via Phase 2 revelations, which is what makes spawn_subagent
a meaningful delegation primitive (otherwise everything is reachable from t=0).

Smoke-tested end-to-end: SSRF on medium scenario reveals 10.0.2.30 + 10.0.2.40,
spawn_subagent against 10.0.2.30 admits it to discovered_hosts, sub-agent runs
2 steps with no findings, return_to_parent closes with productive=False and
-0.05 delegation penalty. All 78 tests still pass.

Files changed (2) hide show
  1. models.py +10 -4
  2. server/security_audit_env_environment.py +175 -11
models.py CHANGED
@@ -29,6 +29,8 @@ class SecurityAuditAction(Action):
29
  "list_tools",
30
  "use_tool",
31
  "submit_finding",
 
 
32
  "generate_report",
33
  ] = Field(..., description="Type of action to take")
34
 
@@ -51,10 +53,14 @@ class LLMJsonAction(BaseModel):
51
 
52
  model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
53
 
54
- action_type: Literal["list_tools", "use_tool", "submit_finding", "generate_report"] = Field(
55
- ...,
56
- description="Which environment action to take",
57
- )
 
 
 
 
58
  tool_name: Optional[str] = Field(
59
  default=None,
60
  description="Tool name when action_type is use_tool",
 
29
  "list_tools",
30
  "use_tool",
31
  "submit_finding",
32
+ "spawn_subagent",
33
+ "return_to_parent",
34
  "generate_report",
35
  ] = Field(..., description="Type of action to take")
36
 
 
53
 
54
  model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
55
 
56
+ action_type: Literal[
57
+ "list_tools",
58
+ "use_tool",
59
+ "submit_finding",
60
+ "spawn_subagent",
61
+ "return_to_parent",
62
+ "generate_report",
63
+ ] = Field(..., description="Which environment action to take")
64
  tool_name: Optional[str] = Field(
65
  default=None,
66
  description="Tool name when action_type is use_tool",
server/security_audit_env_environment.py CHANGED
@@ -12,6 +12,7 @@ infrastructure for security vulnerabilities and compliance gaps.
12
 
13
  import random
14
  from copy import deepcopy
 
15
  from uuid import uuid4
16
 
17
  from openenv.core.env_server.interfaces import Environment
@@ -92,9 +93,14 @@ class SecurityAuditEnvironment(Environment):
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 = []
@@ -126,11 +132,43 @@ class SecurityAuditEnvironment(Environment):
126
  self._state.step_count += 1
127
  steps_remaining = self._state.max_steps - self._state.step_count
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  self._action_history.append({
130
  "step": self._state.step_count,
131
  "action_type": action.action_type,
132
  "tool_name": action.tool_name,
133
  "arguments": action.arguments,
 
134
  })
135
 
136
  if steps_remaining <= 0:
@@ -161,32 +199,153 @@ class SecurityAuditEnvironment(Environment):
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
@@ -307,6 +466,11 @@ class SecurityAuditEnvironment(Environment):
307
  current_phase=self._current_phase(), done=False, reward=-0.02,
308
  )
309
 
 
 
 
 
 
310
  self._submitted_findings.append(finding)
311
 
312
  # Match using same logic as grader for consistency
 
12
 
13
  import random
14
  from copy import deepcopy
15
+ from typing import Optional
16
  from uuid import uuid4
17
 
18
  from openenv.core.env_server.interfaces import Environment
 
93
  self._episode_reward = 0.0
94
  self._last_tool_call = ()
95
  self._rng = random.Random(seed) if seed is not None else random.Random()
96
+ # Reset multi-agent state. Attack surface seeds from initial *visible*
97
+ # hosts only hidden hosts (those gated by `hidden_until`) only join
98
+ # the attack surface when revealed by tool calls, which is what makes
99
+ # spawn_subagent meaningful as a delegation primitive.
100
+ self._attack_surface = {
101
+ ip for ip, info in (self._scenario or {}).get("hosts", {}).items()
102
+ if not info.get("hidden_until")
103
+ }
104
  self._revealed_targets = []
105
  self._active_subagents = {}
106
  self._subagent_outcomes = []
 
132
  self._state.step_count += 1
133
  steps_remaining = self._state.max_steps - self._state.step_count
134
 
135
+ # Sub-agent budget accounting: if a sub-agent is currently active, every
136
+ # step (other than the spawn that created it, or its own return_to_parent)
137
+ # consumes one budget unit. Auto-close on exhaustion so the env never
138
+ # gets stuck in a sub-context.
139
+ active_sid = self._active_subagent_id()
140
+ if active_sid and action.action_type not in ("spawn_subagent",):
141
+ sub = self._active_subagents[active_sid]
142
+ sub["steps_used"] += 1
143
+ if sub["steps_used"] > sub["budget"] and action.action_type != "return_to_parent":
144
+ # Auto-close as unproductive-by-timeout. Surface this to the
145
+ # agent so it knows control is back at the parent.
146
+ sub["status"] = "completed"
147
+ sub["timeout"] = True
148
+ findings_added = len(self._submitted_findings) - sub["findings_at_spawn"]
149
+ productive = findings_added >= 1
150
+ sub["productive"] = productive
151
+ self._subagent_outcomes.append({
152
+ "spawn_id": active_sid,
153
+ "scope": sub["scope"],
154
+ "target": sub["target"],
155
+ "budget": sub["budget"],
156
+ "steps_used": sub["steps_used"],
157
+ "findings_added": findings_added,
158
+ "productive": productive,
159
+ "timeout": True,
160
+ "spawn_step": sub["spawn_step"],
161
+ "closed_at_step": self._state.step_count,
162
+ })
163
+ # The agent's current action still runs at the parent layer.
164
+ # We just clear the active sub-agent flag here.
165
+
166
  self._action_history.append({
167
  "step": self._state.step_count,
168
  "action_type": action.action_type,
169
  "tool_name": action.tool_name,
170
  "arguments": action.arguments,
171
+ "spawn_id": self._active_subagent_id(),
172
  })
173
 
174
  if steps_remaining <= 0:
 
199
  reward=-0.05,
200
  )
201
 
202
+ def _active_subagent_id(self) -> Optional[str]:
203
+ """Return the currently-active sub-agent spawn_id, if any."""
204
+ for sid, info in self._active_subagents.items():
205
+ if info.get("status") == "active":
206
+ return sid
207
+ return None
208
+
209
  def _handle_spawn_subagent(self, action: SecurityAuditAction, steps_remaining: int) -> SecurityAuditObservation:
210
+ """Register a new sub-agent investigation branch.
211
+
212
+ Args (in action.arguments):
213
+ scope: "host" | "endpoint" | "cred"
214
+ target: the IP / endpoint / credential identifier to investigate
215
+ budget: integer step budget for the sub-agent (default 8, capped 15)
216
+
217
+ Validates target is in the dynamic attack_surface (either originally
218
+ scenario-discovered OR revealed by an earlier tool call). Registers
219
+ the sub-agent and adds the target host to discovered_hosts so the
220
+ sub-agent can immediately use scoped tools against it.
221
+ """
222
+ if self._active_subagent_id() is not None:
223
+ return self._obs_with_msg(
224
+ "Cannot spawn — another sub-agent is still active. Call return_to_parent first.",
225
+ steps_remaining, reward=-0.02,
226
+ )
227
+ scope = (action.arguments or {}).get("scope", "host")
228
+ target = (action.arguments or {}).get("target", "")
229
+ budget = int((action.arguments or {}).get("budget", 8) or 8)
230
+ budget = max(2, min(15, budget)) # clamp to a reasonable range
231
+
232
+ if scope not in ("host", "endpoint", "cred"):
233
+ return self._obs_with_msg(
234
+ f"Invalid scope '{scope}'. Use one of: host | endpoint | cred.",
235
+ steps_remaining, reward=-0.02,
236
+ )
237
+ if not target:
238
+ return self._obs_with_msg(
239
+ "spawn_subagent requires a 'target' (e.g. 10.0.2.30 for scope=host).",
240
+ steps_remaining, reward=-0.02,
241
+ )
242
+ if scope == "host" and target not in self._attack_surface:
243
+ return self._obs_with_msg(
244
+ f"Target {target} not in current attack_surface. Discover or reveal it first.",
245
+ steps_remaining, reward=-0.02,
246
+ )
247
+
248
+ spawn_id = f"sub-{len(self._active_subagents) + 1:02d}-{self._state.step_count}"
249
+ self._active_subagents[spawn_id] = {
250
+ "scope": scope,
251
+ "target": target,
252
+ "budget": budget,
253
+ "steps_used": 0,
254
+ "findings_at_spawn": len(self._submitted_findings),
255
+ "spawn_step": self._state.step_count,
256
+ "parent_step": self._state.step_count,
257
+ "status": "active",
258
+ }
259
+
260
+ # If host scope and the target is a revealed-but-not-yet-discovered host,
261
+ # admit it to discovered_hosts now so sub-agent tools can hit it.
262
+ if scope == "host" and target not in self._discovered_hosts and target in self._attack_surface:
263
+ self._discovered_hosts.append(target)
264
+ host_info = self._scenario.get("hosts", {}).get(target, {}) if self._scenario else {}
265
+ if host_info.get("ports"):
266
+ self._discovered_ports[target] = list(host_info.get("ports", []))
267
+
268
+ msg = (
269
+ f"Sub-agent {spawn_id} spawned: scope={scope} target={target} budget={budget} steps. "
270
+ f"Subsequent actions are scoped to this branch until you call return_to_parent "
271
+ f"(or budget exhausts)."
272
+ )
273
  return SecurityAuditObservation(
274
+ tool_output=msg,
275
+ message=msg,
276
  discovered_hosts=self._discovered_hosts,
277
  discovered_services=self._discovered_services,
278
  findings_submitted=len(self._submitted_findings),
279
  steps_remaining=steps_remaining,
280
  current_phase=self._current_phase(),
281
  done=False,
282
+ reward=0.01, # tiny positive — a valid spawn intent; productivity rewarded on return
283
  )
284
 
285
  def _handle_return_to_parent(self, action: SecurityAuditAction, steps_remaining: int) -> SecurityAuditObservation:
286
+ """Close the active sub-agent and record its outcome.
287
+
288
+ Productivity: a sub-agent is "productive" if it submitted ≥1 finding
289
+ during its run (we only count final-grader matches at episode end —
290
+ for the per-step reward, any finding counts to give a fast signal).
291
+
292
+ Reward:
293
+ +0.05 productive
294
+ -0.05 unproductive (penalises spurious branching)
295
+ """
296
+ sid = self._active_subagent_id()
297
+ if not sid:
298
+ return self._obs_with_msg(
299
+ "No active sub-agent to return from. Use spawn_subagent first.",
300
+ steps_remaining, reward=-0.02,
301
+ )
302
+ info = self._active_subagents[sid]
303
+ findings_added = len(self._submitted_findings) - info["findings_at_spawn"]
304
+ productive = findings_added >= 1
305
+ info["status"] = "completed"
306
+ info["findings_added"] = findings_added
307
+ info["productive"] = productive
308
+ info["closed_at_step"] = self._state.step_count
309
+ self._subagent_outcomes.append({
310
+ "spawn_id": sid,
311
+ "scope": info["scope"],
312
+ "target": info["target"],
313
+ "budget": info["budget"],
314
+ "steps_used": info["steps_used"],
315
+ "findings_added": findings_added,
316
+ "productive": productive,
317
+ "spawn_step": info["spawn_step"],
318
+ "closed_at_step": self._state.step_count,
319
+ })
320
+ delegation_reward = 0.05 if productive else -0.05
321
+ msg = (
322
+ f"Sub-agent {sid} closed: {findings_added} findings submitted across "
323
+ f"{info['steps_used']}/{info['budget']} steps. "
324
+ f"{'PRODUCTIVE' if productive else 'unproductive'} (reward {delegation_reward:+.2f})."
325
+ )
326
  return SecurityAuditObservation(
327
+ tool_output=msg,
328
+ message=msg,
329
  discovered_hosts=self._discovered_hosts,
330
  discovered_services=self._discovered_services,
331
  findings_submitted=len(self._submitted_findings),
332
  steps_remaining=steps_remaining,
333
  current_phase=self._current_phase(),
334
  done=False,
335
+ reward=delegation_reward,
336
+ )
337
+
338
+ def _obs_with_msg(self, msg: str, steps_remaining: int, reward: float = 0.0) -> SecurityAuditObservation:
339
+ return SecurityAuditObservation(
340
+ tool_output=msg,
341
+ message=msg,
342
+ discovered_hosts=self._discovered_hosts,
343
+ discovered_services=self._discovered_services,
344
+ findings_submitted=len(self._submitted_findings),
345
+ steps_remaining=steps_remaining,
346
+ current_phase=self._current_phase(),
347
+ done=False,
348
+ reward=reward,
349
  )
350
 
351
  @property
 
466
  current_phase=self._current_phase(), done=False, reward=-0.02,
467
  )
468
 
469
+ # Tag finding with current sub-agent context (if any) so the grader's
470
+ # Delegation Score can credit sub-agent productivity correctly.
471
+ active_sid = self._active_subagent_id()
472
+ if active_sid:
473
+ finding = {**finding, "_spawn_id": active_sid, "_parent_step": self._state.step_count}
474
  self._submitted_findings.append(finding)
475
 
476
  # Match using same logic as grader for consistency