Skip to content

Commit 83092ce

Browse files
committed
Disallow running Agents using system user
1 parent eeba6ec commit 83092ce

5 files changed

Lines changed: 134 additions & 56 deletions

File tree

.basedpyright/baseline.json

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -141,22 +141,6 @@
141141
}
142142
],
143143
"./splunklib/ai/tools.py": [
144-
{
145-
"code": "reportUnknownVariableType",
146-
"range": {
147-
"startColumn": 15,
148-
"endColumn": 31,
149-
"lineCount": 1
150-
}
151-
},
152-
{
153-
"code": "reportUnknownArgumentType",
154-
"range": {
155-
"startColumn": 48,
156-
"endColumn": 56,
157-
"lineCount": 1
158-
}
159-
},
160144
{
161145
"code": "reportUnknownArgumentType",
162146
"range": {

splunklib/ai/agent.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# License for the specific language governing permissions and limitations
1313
# under the License.
1414

15+
import asyncio
1516
import os
1617
from collections.abc import AsyncGenerator, Sequence
1718
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
@@ -46,6 +47,7 @@
4647
_testing_app_id: str | None = None
4748

4849
DEFAULT_TOOL_SETTINGS = ToolSettings(local=False, remote=None)
50+
_SPLUNK_SYSTEM_USER = "splunk-system-user"
4951

5052

5153
@final
@@ -181,9 +183,14 @@ async def _start_agent(self) -> AsyncGenerator[Self]:
181183
"internal error: _impl was not set to None after agent invocation"
182184
)
183185

186+
splunk_username = await asyncio.to_thread(
187+
lambda: _get_splunk_username(self._service)
188+
)
189+
_validate_agent_privileges(splunk_username)
190+
184191
self.logger.debug(f"Creating agent {self.name=}; {self.trace_id=}")
185192

186-
self._tools = await self._load_tools(stack)
193+
self._tools = await self._load_tools(stack, splunk_username)
187194

188195
backend = get_backend()
189196
self._impl = await backend.create_agent(self)
@@ -194,7 +201,9 @@ async def _start_agent(self) -> AsyncGenerator[Self]:
194201

195202
self._impl = None
196203

197-
async def _load_tools(self, stack: AsyncExitStack) -> list[Tool]:
204+
async def _load_tools(
205+
self, stack: AsyncExitStack, splunk_username: str
206+
) -> list[Tool]:
198207
tools: list[Tool] = []
199208
if not self.tool_settings.local and not self.tool_settings.remote:
200209
return tools
@@ -225,7 +234,9 @@ async def _load_tools(self, stack: AsyncExitStack) -> list[Tool]:
225234
if self.tool_settings.remote:
226235
self.logger.debug("Probing MCP Server App availability")
227236
remote_session = await stack.enter_async_context(
228-
connect_remote_mcp(self._service, app_id, self.trace_id)
237+
connect_remote_mcp(
238+
self._service, app_id, self.trace_id, splunk_username
239+
)
229240
)
230241

231242
if remote_session:
@@ -301,6 +312,10 @@ async def invoke_with_data(
301312
)
302313

303314

315+
class PrivilegedExecutionError(Exception):
316+
pass
317+
318+
304319
def _local_tools_path() -> tuple[str | None, str]:
305320
local_tools_path = _testing_local_tools_path
306321
app_id = _testing_app_id
@@ -317,3 +332,38 @@ def _local_tools_path() -> tuple[str | None, str]:
317332
local_tools_path = None
318333

319334
return local_tools_path, app_id
335+
336+
337+
def _get_splunk_username(service: Service) -> str:
338+
class Content(BaseModel):
339+
username: str
340+
341+
class Entry(BaseModel):
342+
content: Content
343+
344+
class ResponseBody(BaseModel):
345+
entry: list[Entry]
346+
347+
# Query Splunk API for the username.
348+
res = service.get(
349+
path_segment="authentication/current-context",
350+
output_mode="json",
351+
)
352+
353+
body = ResponseBody.model_validate_json(str(res.body)) # pyright: ignore[reportUnknownArgumentType]
354+
if len(body.entry) == 0:
355+
return ""
356+
return body.entry[0].content.username
357+
358+
359+
def _validate_agent_privileges(username: str) -> None:
360+
"""Enforces that the agent is not executed under a system account.
361+
362+
Raises:
363+
PrivilegedExecutionError: If the current execution context corresponds
364+
to a disallowed system account.
365+
"""
366+
if username == _SPLUNK_SYSTEM_USER:
367+
raise PrivilegedExecutionError(
368+
f"Agent must not be executed by the system user: {_SPLUNK_SYSTEM_USER}"
369+
)

splunklib/ai/tools.py

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -247,37 +247,11 @@ def _convert_tool_result(
247247
)
248248

249249

250-
def _get_splunk_username(service: Service) -> str:
251-
if service.username:
252-
return service.username
253-
254-
class Content(BaseModel):
255-
username: str
256-
257-
class Entry(BaseModel):
258-
content: Content
259-
260-
class ResponseBody(BaseModel):
261-
entry: list[Entry]
262-
263-
# In case service.username is unavailable, query Splunk API for the username.
264-
# This can happen when a service is created with a token, without username/password.
265-
res = service.get(
266-
path_segment="authentication/current-context",
267-
output_mode="json",
268-
)
269-
270-
body = ResponseBody.model_validate_json(str(res.body))
271-
if len(body.entry) == 0:
272-
return ""
273-
return body.entry[0].content.username
274-
275-
276-
def _get_mcp_token(service: Service) -> str | None:
250+
def _get_mcp_token(splunk_username: str, service: Service) -> str | None:
277251
try:
278252
res = service.get(
279253
path_segment="mcp_token",
280-
username=_get_splunk_username(service),
254+
username=splunk_username,
281255
output_mode="json",
282256
)
283257
except HTTPError as e:
@@ -324,10 +298,13 @@ async def connect_remote_mcp(
324298
service: Service,
325299
app_id: str,
326300
trace_id: str,
301+
splunk_username: str,
327302
) -> AsyncGenerator[ClientSession | None]:
328303
management_url = f"{service.scheme}://{service.host}:{service.port}"
329304
mcp_url = f"{management_url}/services/mcp"
330-
mcp_token = await asyncio.to_thread(lambda: _get_mcp_token(service))
305+
mcp_token = await asyncio.to_thread(
306+
lambda: _get_mcp_token(splunk_username, service)
307+
)
331308
if mcp_token is not None:
332309
async with streamable_http_client(
333310
url=mcp_url,

tests/integration/ai/test_agent_mcp_tools.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
from starlette.routing import Mount, Route
2424

2525
from splunklib.ai import Agent
26+
from splunklib.ai.agent import (
27+
_get_splunk_username, # pyright: ignore[reportPrivateUsage]
28+
)
2629
from splunklib.ai.engines.langchain import LOCAL_TOOL_PREFIX
2730
from splunklib.ai.messages import (
2831
AIMessage,
@@ -50,7 +53,6 @@
5053
)
5154
from splunklib.ai.tools import (
5255
ToolType,
53-
_get_splunk_username, # pyright: ignore[reportPrivateUsage]
5456
locate_app,
5557
)
5658
from splunklib.client import connect
@@ -296,6 +298,12 @@ async def mcp_token_handler(_: Request) -> Response:
296298
return JSONResponse(content={"token": AUTH_TOKEN}, status_code=200)
297299

298300

301+
async def current_context_handler(_: Request) -> Response:
302+
return JSONResponse(
303+
content={"entry": [{"content": {"username": "admin"}}]}, status_code=200
304+
)
305+
306+
299307
class TestRemoteTools(AITestCase):
300308
@patch(
301309
"splunklib.ai.agent._testing_local_tools_path",
@@ -364,6 +372,11 @@ async def dispatch(
364372
routes=[
365373
Mount("/services/mcp", app=mcp.streamable_http_app()),
366374
Route("/services/mcp_token", mcp_token_handler, methods=["GET"]),
375+
Route(
376+
"/services/authentication/current-context",
377+
current_context_handler,
378+
methods=["GET"],
379+
),
367380
],
368381
lifespan=lifespan,
369382
middleware=[Middleware(MCPMiddleware)],
@@ -376,7 +389,6 @@ async def dispatch(
376389
port=port,
377390
splunkToken=AUTH_TOKEN,
378391
autologin=True,
379-
username="admin", # not required, but set to avoid mocking the authentication/current-context endpoint
380392
),
381393
)
382394

@@ -427,15 +439,24 @@ async def dispatch(
427439
async def test_remote_tools_mcp_app_unavailable(self) -> None:
428440
pytest.importorskip("langchain_openai")
429441

430-
async with run_http_server(Starlette(routes=[])) as (host, port):
442+
async with run_http_server(
443+
Starlette(
444+
routes=[
445+
Route(
446+
"/services/authentication/current-context",
447+
current_context_handler,
448+
methods=["GET"],
449+
),
450+
]
451+
)
452+
) as (host, port):
431453
service = await asyncio.to_thread(
432454
lambda: connect(
433455
scheme="http",
434456
host=host,
435457
port=port,
436458
splunkToken=AUTH_TOKEN,
437459
autologin=True,
438-
username="admin", # not required, but set to avoid mocking the authentication/current-context endpoint
439460
),
440461
)
441462

@@ -489,6 +510,11 @@ async def lifespan(app: Starlette) -> AsyncGenerator[None, Any]:
489510
routes=[
490511
Mount("/services/mcp", app=mcp.streamable_http_app()),
491512
Route("/services/mcp_token", mcp_token_handler, methods=["GET"]),
513+
Route(
514+
"/services/authentication/current-context",
515+
current_context_handler,
516+
methods=["GET"],
517+
),
492518
],
493519
lifespan=lifespan,
494520
)
@@ -500,7 +526,6 @@ async def lifespan(app: Starlette) -> AsyncGenerator[None, Any]:
500526
port=port,
501527
splunkToken=AUTH_TOKEN,
502528
autologin=True,
503-
username="admin", # not required, but set to avoid mocking the authentication/current-context endpoint
504529
),
505530
)
506531

@@ -579,6 +604,11 @@ async def lifespan(app: Starlette) -> AsyncGenerator[None, Any]:
579604
routes=[
580605
Mount("/services/mcp", app=mcp.streamable_http_app()),
581606
Route("/services/mcp_token", mcp_token_handler, methods=["GET"]),
607+
Route(
608+
"/services/authentication/current-context",
609+
current_context_handler,
610+
methods=["GET"],
611+
),
582612
],
583613
lifespan=lifespan,
584614
)
@@ -590,7 +620,6 @@ async def lifespan(app: Starlette) -> AsyncGenerator[None, Any]:
590620
port=port,
591621
splunkToken=AUTH_TOKEN,
592622
autologin=True,
593-
username="admin", # not required, but set to avoid mocking the authentication/current-context endpoint
594623
),
595624
)
596625

@@ -732,6 +761,11 @@ async def lifespan(_app: Starlette) -> AsyncGenerator[None, Any]:
732761
routes=[
733762
Mount("/services/mcp", app=mcp.streamable_http_app()),
734763
Route("/services/mcp_token", mcp_token_handler, methods=["GET"]),
764+
Route(
765+
"/services/authentication/current-context",
766+
current_context_handler,
767+
methods=["GET"],
768+
),
735769
],
736770
lifespan=lifespan,
737771
)
@@ -743,8 +777,6 @@ async def lifespan(_app: Starlette) -> AsyncGenerator[None, Any]:
743777
port=port,
744778
splunkToken=AUTH_TOKEN,
745779
autologin=True,
746-
# To avoid mocking `authentication/current-context` endpoint
747-
username="admin",
748780
),
749781
)
750782

tests/unit/ai/test_security.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
import pytest
1919

20+
from splunklib.ai import Agent, OpenAIModel
21+
from splunklib.ai.agent import PrivilegedExecutionError
2022
from splunklib.ai.messages import AgentResponse, AIMessage, HumanMessage
2123
from splunklib.ai.middleware import (
2224
AgentMiddlewareHandler,
@@ -28,6 +30,8 @@
2830
detect_injection,
2931
truncate_input,
3032
)
33+
from splunklib.client import Service
34+
from splunklib.data import Record
3135

3236

3337
class TestDetectInjection(unittest.TestCase):
@@ -168,3 +172,34 @@ async def handler(_request: AgentRequest) -> AgentResponse[Any]:
168172
)
169173
await middleware.agent_middleware(request, handler)
170174
assert called
175+
176+
177+
class TestPrivilegedExecution(unittest.IsolatedAsyncioTestCase):
178+
@pytest.mark.asyncio
179+
async def test_agent_with_system_user(self) -> None:
180+
model = OpenAIModel(
181+
model="test-model", base_url="test-url", api_key="test-api-key"
182+
)
183+
184+
def handler(url: str, _message: dict[str, Any], **_kwargs: dict[str, Any]):
185+
assert (
186+
url
187+
== "https://localhost:8089/services/authentication/current-context?output_mode=json"
188+
)
189+
return Record(
190+
{
191+
"status": 200,
192+
"headers": [],
193+
"body": '{"entry": [{"content": {"username": "splunk-system-user"}}]}',
194+
}
195+
)
196+
197+
service = Service(token="test-token", handler=handler)
198+
199+
with pytest.raises(PrivilegedExecutionError, match="splunk-system-user"):
200+
async with Agent(
201+
model=model,
202+
system_prompt="Your name is stefan",
203+
service=service,
204+
):
205+
...

0 commit comments

Comments
 (0)