Skip to content

Commit 0951ca0

Browse files
committed
feat: implement session-based tracking for malicious behavior
Adds `BehaviorRecord` and `BehaviorRecordManager` to monitor gatekeeper verdicts per session. If a session accumulates 4 total or 3 consecutive malicious actions, it is permanently flagged as security compromised. Once flagged, all subsequent scripts executed in that session will be forced to require user confirmation, regardless of the script's default requirements. Also the subsequent execution request will be force to use either run_script_with_confirmation or run_script_interactive to get human review in the loop if the previous script was marked as malicious. The security compromised flag is emitted through the tool call result for mcp-app. A warning message will be shown in the mcp-app to notify uses about the malicious behaviors.
1 parent 867d26b commit 0951ca0

4 files changed

Lines changed: 150 additions & 20 deletions

File tree

mcp-app/src/global.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@
111111
.execution-state-tag {
112112
@apply border border-app-border-primary rounded-md w-fit px-2 text-sm
113113
}
114+
115+
.security-warning {
116+
@apply bg-red-200 text-red-600 rounded-lg p-4 mb-4
117+
}
114118
}
115119

116120
* {

mcp-app/src/run-script-app.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@ function RunScriptAppInner({
251251
<div className="app-container">
252252
<div className="script-main-box">
253253
<div className="mb-4">
254+
{validatedToolResult.isSecurityCompromised && (
255+
<p className="security-warning">
256+
Suspicious activity detected - your chat client may be under
257+
attack. Please examine previous tool calls in detail, and if you
258+
have any doubts, do not approve this command and terminate this
259+
chat session.
260+
</p>
261+
)}
262+
254263
{/* TODO: we can dynamically inject the platform that users are using here */}
255264
<p>
256265
Goose wants to perform the following action on{" "}

mcp-app/src/types.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,27 @@ export const ExecuteScriptResultSchema = z.object({
3333

3434
export type ExecuteScriptResult = z.infer<typeof ExecuteScriptResultSchema>;
3535

36-
export const McpAppToolResultSchema = z.object({
37-
status: z.enum([
38-
"OK",
39-
"BAD_DESCRIPTION",
40-
"POLICY",
41-
"MODIFIES_SYSTEM",
42-
"UNCLEAR",
43-
"DANGEROUS",
44-
"MALICIOUS",
45-
]),
46-
detail: z.string(),
47-
id: z.string(),
48-
});
36+
export const McpAppToolResultSchema = z
37+
.object({
38+
status: z.enum([
39+
"OK",
40+
"BAD_DESCRIPTION",
41+
"POLICY",
42+
"MODIFIES_SYSTEM",
43+
"UNCLEAR",
44+
"DANGEROUS",
45+
"MALICIOUS",
46+
]),
47+
detail: z.string(),
48+
id: z.string(),
49+
is_security_compromised: z.boolean(),
50+
})
51+
.transform((data) => ({
52+
status: data.status,
53+
detail: data.detail,
54+
id: data.id,
55+
isSecurityCompromised: data.is_security_compromised,
56+
}));
4957

5058
export type McpAppToolResult = z.infer<typeof McpAppToolResultSchema>;
5159

src/linux_mcp_server/tools/run_script.py

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,77 @@ def set_script_state(self, id: str, new_state: ExecutionState):
154154

155155

156156
script_store = ScriptStore()
157+
MAX_CONSECUTIVE_MALICIOUS_ACTIONS = 3
158+
MAX_TOTAL_MALICIOUS_ACTIONS = 4
157159

158160

161+
class BehaviorRecord:
162+
"""
163+
Tracks gatekeeper verdicts for a single session to detect malicious behavior.
164+
165+
A session is flagged as security compromised if it accumulates MAX_TOTAL_MALICIOUS_ACTIONS
166+
total malicious actions or MAX_CONSECUTIVE_MALICIOUS_ACTIONS consecutive malicious actions.
167+
Once flagged, the session is permanently marked and all subsequent scripts require confirmation.
168+
"""
169+
170+
def __init__(self):
171+
self._consecutive_malicious_action_counts = 0
172+
self._total_malicious_action_counts = 0
173+
self._previous_action_status = None
174+
self._current_action_status = None
175+
self._is_security_compromised: bool = False
176+
177+
def add_record(self, status: GatekeeperStatus):
178+
"""
179+
Record a gatekeeper verdict and update the security compromised flag if
180+
thresholds are met.
181+
"""
182+
# No need to update the record if it's already considered security compromised
183+
if self._is_security_compromised:
184+
return
185+
186+
self._previous_action_status = self._current_action_status
187+
self._current_action_status = status
188+
189+
if status == GatekeeperStatus.MALICIOUS:
190+
self._consecutive_malicious_action_counts += 1
191+
self._total_malicious_action_counts += 1
192+
else:
193+
self._consecutive_malicious_action_counts = 0
194+
195+
# Check if the record matches the conditions of being considered as malicious
196+
if self._total_malicious_action_counts >= MAX_TOTAL_MALICIOUS_ACTIONS:
197+
self._is_security_compromised = True
198+
return
199+
200+
if self._consecutive_malicious_action_counts >= MAX_CONSECUTIVE_MALICIOUS_ACTIONS:
201+
self._is_security_compromised = True
202+
203+
@property
204+
def is_security_compromised(self) -> bool:
205+
return self._is_security_compromised
206+
207+
@property
208+
def is_previous_action_malicious(self) -> bool:
209+
return self._previous_action_status == GatekeeperStatus.MALICIOUS
210+
211+
212+
class BehaviorRecordManager:
213+
"""Manages per-session BehaviorRecords, creating them on first access."""
214+
215+
def __init__(self):
216+
self._records: dict[str, BehaviorRecord] = dict()
217+
218+
def get_record_by_session_id(self, session_id: str) -> BehaviorRecord:
219+
"""Return the BehaviorRecord for a session, creating one if it doesn't exist."""
220+
if session_id not in self._records:
221+
self._records[session_id] = BehaviorRecord()
222+
223+
return self._records[session_id]
224+
225+
226+
behavior_record_manager = BehaviorRecordManager()
227+
159228
BASH_STRICT_PREAMBLE = "set -euo pipefail; "
160229

161230
SYSTEMD_RUN_ARGS = [
@@ -209,6 +278,7 @@ class RunScriptInteractiveResult(BaseModel):
209278
id: str
210279
status: GatekeeperStatus
211280
detail: str
281+
is_security_compromised: bool
212282

213283

214284
# class UserInfo(BaseModel):
@@ -314,9 +384,16 @@ async def run_script_interactive(
314384
host: Host = None,
315385
) -> ToolResult:
316386
script_details = script_store.get_script_details(token)
387+
behavior_record = behavior_record_manager.get_record_by_session_id(ctx.session_id)
388+
389+
needs_confirmation = (
390+
script_details.needs_confirmation
391+
or behavior_record.is_security_compromised
392+
or behavior_record.is_previous_action_malicious
393+
)
317394

318395
# Verify that this script requires confirmation
319-
if not script_details.needs_confirmation:
396+
if not needs_confirmation:
320397
raise ToolError("This script does not require confirmation. Use run_script instead of run_script_interactive.")
321398

322399
# Check if the passed parameters match the stored script details
@@ -338,6 +415,8 @@ async def run_script_interactive(
338415
(BASH_STRICT_PREAMBLE + script) if script_type == SCRIPT_TYPE_BASH else script,
339416
readonly=readonly,
340417
)
418+
behavior_record.add_record(gatekeeper_result.status)
419+
341420
if gatekeeper_result.status != GatekeeperStatus.OK:
342421
script_store.set_script_state(token, "rejected-gatekeeper")
343422
raise ToolError(gatekeeper_result.description)
@@ -352,7 +431,12 @@ async def run_script_interactive(
352431
)
353432
]
354433

355-
structured_content_obj = RunScriptInteractiveResult(id=result_id, status=GatekeeperStatus.OK, detail="")
434+
structured_content_obj = RunScriptInteractiveResult(
435+
id=result_id,
436+
status=GatekeeperStatus.OK,
437+
detail="",
438+
is_security_compromised=behavior_record.is_security_compromised,
439+
)
356440

357441
return ToolResult(content=content, structured_content=structured_content_obj.model_dump())
358442

@@ -413,23 +497,32 @@ async def validate_script(
413497
readonly=readonly,
414498
)
415499

500+
behavior_record = behavior_record_manager.get_record_by_session_id(ctx.session_id)
501+
behavior_record.add_record(gatekeeper_result.status)
502+
416503
id = script_store.add_script(description, script, script_type, host, readonly)
417504
script_details = script_store.get_script_details(id)
418505

419506
if gatekeeper_result.status != GatekeeperStatus.OK:
420507
script_store.set_script_state(id, "rejected-gatekeeper")
421508
raise ToolError(gatekeeper_result.description)
422509

510+
needs_confirmation = (
511+
script_details.needs_confirmation
512+
or behavior_record.is_security_compromised
513+
or behavior_record.is_previous_action_malicious
514+
)
515+
423516
result = ToolResult(
424517
content=[
425518
TextContent(
426519
type="text",
427-
text=f"Script passed gatekeeper validation and is stored with ID {id}. Please use {_pick_execution_tool(script_details.needs_confirmation)} to execute the validated script.",
520+
text=f"Script passed gatekeeper validation and is stored with ID {id}. Please use {_pick_execution_tool(needs_confirmation)} to execute the validated script.",
428521
)
429522
],
430523
structured_content={
431524
"token": id,
432-
"needs_confirmation": script_details.needs_confirmation,
525+
"needs_confirmation": needs_confirmation,
433526
},
434527
)
435528
return result
@@ -448,10 +541,17 @@ async def run_script(
448541
token: t.Annotated[str, Field(description="The token returned by the validate_script tool.")],
449542
) -> str:
450543
script_details = script_store.get_script_details(token)
544+
behavior_record = behavior_record_manager.get_record_by_session_id(ctx.session_id)
545+
546+
needs_confirmation = (
547+
script_details.needs_confirmation
548+
or behavior_record.is_security_compromised
549+
or behavior_record.is_previous_action_malicious
550+
)
451551

452552
# Verify that this script doesn't require confirmation
453-
if script_details.needs_confirmation:
454-
raise ToolError(f"This script requires confirmation. Use {_pick_execution_tool(True)} instead of run_script.")
553+
if needs_confirmation:
554+
raise ToolError("This script requires confirmation. Use run_script_with_confirmation instead of run_script.")
455555

456556
script_store.set_script_state(token, "executing")
457557
try:
@@ -498,9 +598,16 @@ async def run_script_with_confirmation(
498598
host: Host = None,
499599
) -> str:
500600
script_details = script_store.get_script_details(token)
601+
behavior_record = behavior_record_manager.get_record_by_session_id(ctx.session_id)
602+
603+
needs_confirmation = (
604+
script_details.needs_confirmation
605+
or behavior_record.is_security_compromised
606+
or behavior_record.is_previous_action_malicious
607+
)
501608

502609
# Verify that this script requires confirmation
503-
if not script_details.needs_confirmation:
610+
if not needs_confirmation:
504611
raise ToolError(
505612
"This script does not require confirmation. Use run_script instead of run_script_with_confirmation."
506613
)
@@ -527,6 +634,8 @@ async def run_script_with_confirmation(
527634
(BASH_STRICT_PREAMBLE + script) if script_type == SCRIPT_TYPE_BASH else script,
528635
readonly=readonly,
529636
)
637+
behavior_record.add_record(gatekeeper_result.status)
638+
530639
if gatekeeper_result.status != GatekeeperStatus.OK:
531640
script_store.set_script_state(token, "rejected-gatekeeper")
532641
raise ToolError(gatekeeper_result.description)

0 commit comments

Comments
 (0)