Note: GitHub private vulnerability reporting is disabled for this repository and no security contact is listed, so this is being reported as a public issue.
Summary
PentestAgent stores an engagement's authorized targets in the workspace (meta.yaml) and checks every tool call whose arguments name a target against that list (pentestagent/agents/base_agent.py:613-642). The check is fail-open: it only runs when WorkspaceManager.get_active() returns a workspace name. set_active() writes .active with marker.write_text(name) (pentestagent/workspaces/manager.py:144), which truncates the file before writing it, so an interrupted write can leave an existing but empty .active. get_active() then returns "" (manager.py:183-187), if active: is false, and the entire scope check is skipped: the tool call proceeds with an out-of-scope target exactly as if no workspace had ever been configured. There is no error or warning from the gate; the empty marker is simply treated as "no active workspace".
Affected versions
Confirmed on commit 8dde193e (main, 2026-10-26; pyproject.toml version 0.2.0). The repository has no release tags, so I cannot assert a version range.
Details
Affected components:
pentestagent/workspaces/manager.py:183-187—get_active()returns""both when the marker does not exist and when it exists but is empty/whitespace.pentestagent/workspaces/manager.py:140-144—set_active()callsmarker.write_text(name, encoding="utf-8"), which truncates first: an interrupted write can leave an empty.active.pentestagent/workspaces/manager.py:120-133— by contrast,_read_meta()raisesWorkspaceErroron a YAML parse error; the codebase already treats a malformed security state file as an error there.pentestagent/agents/base_agent.py:613-642— the gate:active = wm.get_active(); ifactive,allowed = wm.list_targets(active); candidates fromvalidation.gather_candidate_targets();if active and out_of_scope: block; theelsebranch at line 643 proceeds totool.execute(...)at line 689.pentestagent/workspaces/validation.py:53-119—is_target_in_scope()is fail-closed (returnsFalseon normalization errors, exceptions, and an empty allow-list); the defect is that it is not called at all whenactiveis empty.
Root cause: the scope state is fail-open because it has no "unknown" value. "Never configured" and "state lost" produce the same "", and the default that follows (if active: false) is the permissive one, while the enforcement mode that was in force is dropped without an error or a warning.
Authorization impact: in a session that was scoped by a workspace, this is an authorization bypass (CWE-863), not a missing input filter. The only basis for allowing a target-bearing call is a successful scope decision; with an empty marker that decision is never made, yet the call executes with the operator's authority. A downstream tool cannot recover the authorization the gate failed to enforce.
Scope note (to avoid overlap with the existing public report): gather_candidate_targets() only inspects the keys target, host, hostname, ip, address, url, hosts, targets, so terminal calls (which only carry command) are not covered at all — that is the separate, already-public issue [#107](https://github.com/GH05TCREW/pentestagent/issues/107). This report is about the calls whose arguments the gate inspects (the browser tool's url, and MCP/custom tools whose schema names a target), whose enforcement silently disappears when the marker is empty. Fixing #107 does not fix this, and fixing this does not fix #107. The PoC uses terminal only as a convenient executor (it ignores the extra target key) and then repeats the check with a target-bearing tool, which is the class this report is about.
Attack path (conceptual): an operator sets a workspace with authorized targets; .active is truncated by an interrupted write (or cleared by a script/disk error); a later run starts with an empty marker; the agent's tool call names an out-of-scope target in a url/target argument; get_active() returns ""; the gate is skipped and the call executes.
Affected deployments: TUI/CLI sessions, and long-running mcp_server or containerized deployments started in a workspace-active directory, where an interrupted state write is plausible. The gate is only relevant for tools whose arguments carry a target.
Proof of concept
Drive the shipped BaseAgent tool-execution path (_execute_tools() -> _execute_single()) at commit 8dde193e. The workspace manager, validation, gate, and shipped terminal tool are used as-is; the Runtime transport is local (production uses DockerRuntime or a socket ToolServer), and step 3 adds a minimal target-bearing tool to model the MCP/custom-tool case.
- Create workspace
acme, add target10.0.0.0/24, activate it; call the terminal tool with{"command": "touch <temp-dir>/marker_control", "target": "10.9.9.9"}(structured target argument, the shape the gate inspects).get_active()isacme,list_targets()is["10.0.0.0/24"],10.9.9.9is out of scope, the call is refused with "Out-of-scope target(s) … operator confirmation required", the Runtime receives no command, and no marker exists (control). - Truncate
workspaces/.activeto an empty file (an interruptedwrite_text), then repeat the same call.get_active()is"",if active:is false, theterminaltool runs, the Runtime receives the command, and the marker exists (fault). - Repeat control and fault with a minimal target-bearing tool using the project's
Tool/ToolSchemaregistry (schema declarestarget; the tool body writes the received target to a marker, as an MCP/custom tool would act on it). Control: the product returns the same out-of-scope error before the tool runs. Fault: the tool is invoked and its marker contains10.9.9.9, so the out-of-scope value reaches the tool that uses it. - Expected difference: control = refused before the tool runs, no marker; fault = tool runs, marker present (and contains
10.9.9.9for the target-bearing tool). Within each pair, only the content of.activediffers.
Impact
For the tool calls the scope gate covers, a single empty state file removes the engagement's authorization boundary: the tool is invoked with a target the operator never authorized, silently. For a penetration-testing tool that boundary is the legal/contractual authorization, so the consequence is an out-of-scope action rather than a lost feature. Trigger requires a local .active that is empty; the impact is the out-of-scope action, not a new code-execution primitive.
Suggested severity
CWE-636 (Not Failing Securely) and CWE-863 (Incorrect Authorization). CVSS 3.1: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:L/I:L/A:L = 5.3 (Medium).
Remediation
- Make
get_active()tri-state: absent marker means "no workspace"; a present-but-empty or unreadable marker is an error that fails closed for calls carrying a candidate target. - Write
.activeatomically (tmp +os.replace), as the rest of the state handling expects. - Do not silently treat an unresolvable workspace name as "no workspace".
- Separately, cover
terminalcommand text (issue #107) or document explicitly that the scope gate only covers structured target arguments.
References
manager.py:183-187—get_active()manager.py:140-144—set_active()manager.py:120-133—_read_meta()base_agent.py:613-642— scope gatevalidation.py:53-119—is_target_in_scope()- [Issue #107 — terminal free-text command scope bypass](https://github.com/GH05TCREW/pentestagent/issues/107)
- [Commit
8dde193e](
Activity
0No activity yet.