Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions internal/rules/lc117_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package rules_test

import (
"testing"

"github.com/trustabl/trustabl/internal/models"
)

func TestLC117HumanApprovalPrerequisites(t *testing.T) {
middleware := func(items ...models.Expr) *models.KwargTree {
return &models.KwargTree{
Value: &models.Expr{
Kind: models.ExprList,
List: items,
},
}
}
checkpointer := &models.KwargTree{
Value: &models.Expr{Kind: models.ExprCall, Text: "InMemorySaver()"},
}

cases := []struct {
name string
agent models.AgentDef
wantFires bool
}{
{
name: "fires with privileged tool and no approval prerequisites",
agent: models.AgentDef{
SDK: models.SDKLangChain, Class: "CreateAgent", Language: models.LanguagePython,
HostedToolRefs: []models.HostedToolRef{{Class: "ShellTool"}},
},
wantFires: true,
},
{
name: "fires with middleware but no checkpointer",
agent: models.AgentDef{
SDK: models.SDKLangChain, Class: "CreateAgent", Language: models.LanguagePython,
HostedToolRefs: []models.HostedToolRef{{Class: "ShellTool"}},
Kwargs: &models.KwargTree{Children: map[string]*models.KwargTree{
"middleware": middleware(models.Expr{Kind: models.ExprCall, Text: "HumanInTheLoopMiddleware(...)"}),
}},
},
wantFires: true,
},
{
name: "fires with empty middleware even when checkpointer is present",
agent: models.AgentDef{
SDK: models.SDKLangChain, Class: "CreateAgent", Language: models.LanguagePython,
HostedToolRefs: []models.HostedToolRef{{Class: "PythonREPLTool"}},
Kwargs: &models.KwargTree{Children: map[string]*models.KwargTree{
"middleware": middleware(),
"checkpointer": checkpointer,
}},
},
wantFires: true,
},
{
name: "silent with middleware and checkpointer",
agent: models.AgentDef{
SDK: models.SDKLangChain, Class: "CreateAgent", Language: models.LanguagePython,
HostedToolRefs: []models.HostedToolRef{{Class: "ShellTool"}},
Kwargs: &models.KwargTree{Children: map[string]*models.KwargTree{
"middleware": middleware(models.Expr{Kind: models.ExprCall, Text: "HumanInTheLoopMiddleware(...)"}),
"checkpointer": checkpointer,
}},
},
wantFires: false,
},
{
name: "silent without a privileged execution tool",
agent: models.AgentDef{
SDK: models.SDKLangChain, Class: "CreateAgent", Language: models.LanguagePython,
},
wantFires: false,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d := loadAgentRule(t, "LC-117")
if !d.Applies(tc.agent) {
if tc.wantFires {
t.Fatalf("LC-117 does not apply to %s/%s", tc.agent.SDK, tc.agent.Class)
}
return
}

fired := false
for _, f := range d.Detect(tc.agent, models.RepoInventory{}) {
if f.RuleID == "LC-117" {
fired = true
break
}
}
if fired != tc.wantFires {
t.Fatalf("LC-117 fired=%v, want %v", fired, tc.wantFires)
}
})
}
}
39 changes: 39 additions & 0 deletions internal/rules/policies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4947,6 +4947,45 @@ var policyAgentRuleCases = []policyAgentCase{
},
models.RepoInventory{},
false},
{"LC-117 fires when a privileged create_agent has no approval prerequisites", "LC-117",
models.AgentDef{
SDK: models.SDKLangChain, Class: "CreateAgent", Language: models.LanguagePython,
HostedToolRefs: []models.HostedToolRef{{Class: "ShellTool"}},
},
models.RepoInventory{},
true},
{"LC-117 fires when middleware is present but no checkpointer", "LC-117",
models.AgentDef{
SDK: models.SDKLangChain, Class: "CreateAgent", Language: models.LanguagePython,
HostedToolRefs: []models.HostedToolRef{{Class: "PythonREPLTool"}},
Kwargs: &models.KwargTree{Children: map[string]*models.KwargTree{
"middleware": {Value: &models.Expr{Kind: models.ExprList, List: []models.Expr{
{Kind: models.ExprCall, Text: "HumanInTheLoopMiddleware(...)"},
}}},
}},
},
models.RepoInventory{},
true},
{"LC-117 silent with both middleware and a checkpointer", "LC-117",
models.AgentDef{
SDK: models.SDKLangChain, Class: "CreateAgent", Language: models.LanguagePython,
HostedToolRefs: []models.HostedToolRef{{Class: "ShellTool"}},
Kwargs: &models.KwargTree{Children: map[string]*models.KwargTree{
"middleware": {Value: &models.Expr{Kind: models.ExprList, List: []models.Expr{
{Kind: models.ExprCall, Text: "HumanInTheLoopMiddleware(...)"},
}}},
"checkpointer": {Value: &models.Expr{Kind: models.ExprCall, Text: "InMemorySaver()"}},
}},
},
models.RepoInventory{},
false},
{"LC-117 silent without a privileged execution tool", "LC-117",
models.AgentDef{
SDK: models.SDKLangChain, Class: "CreateAgent", Language: models.LanguagePython,
HostedToolRefs: []models.HostedToolRef{{Class: "TavilySearchResults"}},
},
models.RepoInventory{},
false},
{"VAI-101 fires when an instrumented repo has a call with no telemetry", "VAI-101",
models.AgentDef{
SDK: models.SDKVercelAI, Class: "GenerateText", Language: models.LanguageTypeScript,
Expand Down
34 changes: 34 additions & 0 deletions testdata/rules-fixture/langchain/agent_safety.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,37 @@ rules:
keep MemorySaver for tests. Invoke with
config={"configurable": {"thread_id": ...}} so each conversation resumes
from its own persisted thread.

- id: LC-117
title: LangChain privileged create_agent lacks human-approval prerequisites
severity: high
confidence: 0.7
language: python
applies_to:
- langchain_agent
scope: agent
match:
all:
- agent_class:
- CreateAgent
- agent_uses_hosted_tool_class:
- PythonREPLTool
- PythonAstREPLTool
- ShellTool
- any:
- agent_kwarg_list_empty:
- middleware
- agent_kwarg_missing:
- checkpointer
explanation: >
This LangChain v1 create_agent exposes a built-in that can execute arbitrary
Python or shell commands, but it lacks middleware and/or checkpoint state
needed to support a resumable human approval boundary. Without those
structural prerequisites, the model can reach a high-impact execution tool
without an explicit interrupt/resume control point that Trustabl can verify.
fix: >
Add human-in-the-loop approval middleware for the privileged tool and pass a
checkpointer so interrupted execution can be persisted and resumed after the
decision. Keep the REPL/shell tool sandboxed and narrowly scoped. This rule
only verifies the structural prerequisites; review that the middleware is in
fact configured to require approval for the privileged tool.
Loading