Skip to content

Commit 1353f15

Browse files
AspdelusJinxuan Zhang
andauthored
fix(core): relax active skill tool restrictions by default (#72)
* fix(core): relax active skill tool restrictions by default * docs: note active skill restriction default change --------- Co-authored-by: Jinxuan Zhang <aspdelus@JinxuandeMac-mini.local>
1 parent c1f56fb commit 1353f15

18 files changed

Lines changed: 381 additions & 35 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [4.1.0] - Unreleased
9+
10+
### Changed
11+
12+
- Active skill `allowed-tools` no longer globally deny ordinary session tool calls
13+
by default. Tool calls continue through permission policy, hooks, HITL, and AHP;
14+
`SessionOptions::with_active_skill_tool_restrictions(true)` (Node:
15+
`enforceActiveSkillToolRestrictions`) restores the legacy global restriction
16+
behavior. Skill-local execution still enforces each skill's `allowed-tools`.
17+
818
## [4.0.0] - 2026-06-21
919

1020
Milestone release: **filesystem-first agents**. A single directory

core/src/agent.rs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ pub(crate) struct AgentConfig {
9292
pub hook_engine: Option<Arc<dyn HookExecutor>>,
9393
/// Optional skill registry for tool permission enforcement
9494
pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
95+
/// When true, active skill `allowed-tools` restrict ordinary session tool calls.
96+
///
97+
/// The default is false: active skills may inject instructions, but ordinary
98+
/// tool calls continue to the host permission/AHP/HITL approval chain.
99+
/// Skill invocations still enable this for their child execution context.
100+
pub enforce_active_skill_tool_restrictions: bool,
95101
/// Max consecutive malformed-tool-args errors before aborting (default: 2).
96102
///
97103
/// When the LLM returns tool arguments with `__parse_error`, the error is
@@ -181,6 +187,10 @@ impl std::fmt::Debug for AgentConfig {
181187
"skill_registry",
182188
&self.skill_registry.as_ref().map(|r| r.len()),
183189
)
190+
.field(
191+
"enforce_active_skill_tool_restrictions",
192+
&self.enforce_active_skill_tool_restrictions,
193+
)
184194
.field("max_parse_retries", &self.max_parse_retries)
185195
.field("tool_timeout_ms", &self.tool_timeout_ms)
186196
.field("max_parallel_tasks", &self.max_parallel_tasks)
@@ -221,6 +231,7 @@ impl Default for AgentConfig {
221231
goal_tracking: false,
222232
hook_engine: None,
223233
skill_registry: Some(Arc::new(crate::skills::SkillRegistry::with_builtins())),
234+
enforce_active_skill_tool_restrictions: false,
224235
max_parse_retries: 2,
225236
tool_timeout_ms: None,
226237
max_parallel_tasks: DEFAULT_MAX_PARALLEL_TASKS,
@@ -695,6 +706,7 @@ pub struct ToolCommand {
695706
tool_args: Value,
696707
tool_context: ToolContext,
697708
skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
709+
enforce_active_skill_tool_restrictions: bool,
698710
}
699711

700712
impl ToolCommand {
@@ -705,13 +717,15 @@ impl ToolCommand {
705717
tool_args: Value,
706718
tool_context: ToolContext,
707719
skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
720+
enforce_active_skill_tool_restrictions: bool,
708721
) -> Self {
709722
Self {
710723
tool_executor,
711724
tool_name,
712725
tool_args,
713726
tool_context,
714727
skill_registry,
728+
enforce_active_skill_tool_restrictions,
715729
}
716730
}
717731
}
@@ -720,25 +734,27 @@ impl ToolCommand {
720734
impl SessionCommand for ToolCommand {
721735
async fn execute(&self) -> Result<Value> {
722736
// Check skill-based tool permissions
723-
if let Some(registry) = &self.skill_registry {
724-
// If there are instruction skills with tool restrictions, check permissions
725-
let restricting_skills = registry.global_tool_restricting_skills();
726-
727-
if !restricting_skills.is_empty() {
728-
let mut allowed = false;
729-
730-
for skill in &restricting_skills {
731-
if skill.is_tool_allowed(&self.tool_name) {
732-
allowed = true;
733-
break;
737+
if self.enforce_active_skill_tool_restrictions {
738+
if let Some(registry) = &self.skill_registry {
739+
// If there are instruction skills with tool restrictions, check permissions
740+
let restricting_skills = registry.global_tool_restricting_skills();
741+
742+
if !restricting_skills.is_empty() {
743+
let mut allowed = false;
744+
745+
for skill in &restricting_skills {
746+
if skill.is_tool_allowed(&self.tool_name) {
747+
allowed = true;
748+
break;
749+
}
734750
}
735-
}
736751

737-
if !allowed {
738-
return Err(anyhow::anyhow!(
752+
if !allowed {
753+
return Err(anyhow::anyhow!(
739754
"Tool '{}' is not allowed by any active skill. Active skills restrict tools to their allowed-tools lists.",
740755
self.tool_name
741756
));
757+
}
742758
}
743759
}
744760
}

core/src/agent/extra_agent_tests.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@ async fn test_tool_command_command_type() {
827827
tool_name: "read".to_string(),
828828
tool_args: serde_json::json!({"file": "test.rs"}),
829829
skill_registry: None,
830+
enforce_active_skill_tool_restrictions: false,
830831
tool_context: test_tool_context(),
831832
};
832833
assert_eq!(cmd.command_type(), "read");
@@ -841,11 +842,77 @@ async fn test_tool_command_payload() {
841842
tool_name: "read".to_string(),
842843
tool_args: args.clone(),
843844
skill_registry: None,
845+
enforce_active_skill_tool_restrictions: false,
844846
tool_context: test_tool_context(),
845847
};
846848
assert_eq!(cmd.payload(), args);
847849
}
848850

851+
#[tokio::test]
852+
async fn test_tool_command_ignores_active_skill_restrictions_by_default() {
853+
let registry = crate::skills::SkillRegistry::new();
854+
registry.register_unchecked(Arc::new(crate::skills::Skill {
855+
name: "read-only".to_string(),
856+
description: String::new(),
857+
allowed_tools: Some("read(*)".to_string()),
858+
disable_model_invocation: false,
859+
kind: crate::skills::SkillKind::Instruction,
860+
content: String::new(),
861+
tags: Vec::new(),
862+
version: None,
863+
}));
864+
865+
let cmd = ToolCommand {
866+
tool_executor: Arc::new(ToolExecutor::new("/tmp".to_string())),
867+
tool_name: "not_a_tool".to_string(),
868+
tool_args: serde_json::json!({}),
869+
skill_registry: Some(Arc::new(registry)),
870+
enforce_active_skill_tool_restrictions: false,
871+
tool_context: test_tool_context(),
872+
};
873+
874+
let output = cmd.execute().await.unwrap();
875+
let output = output["output"].as_str().unwrap_or_default();
876+
assert!(
877+
!output.contains("not allowed by any active skill"),
878+
"default mode must let the command reach the tool executor, got: {output}"
879+
);
880+
assert!(
881+
output.contains("Unknown tool"),
882+
"default mode should reach the tool executor, got: {output}"
883+
);
884+
}
885+
886+
#[tokio::test]
887+
async fn test_tool_command_enforces_active_skill_restrictions_in_legacy_mode() {
888+
let registry = crate::skills::SkillRegistry::new();
889+
registry.register_unchecked(Arc::new(crate::skills::Skill {
890+
name: "read-only".to_string(),
891+
description: String::new(),
892+
allowed_tools: Some("read(*)".to_string()),
893+
disable_model_invocation: false,
894+
kind: crate::skills::SkillKind::Instruction,
895+
content: String::new(),
896+
tags: Vec::new(),
897+
version: None,
898+
}));
899+
900+
let cmd = ToolCommand {
901+
tool_executor: Arc::new(ToolExecutor::new("/tmp".to_string())),
902+
tool_name: "not_a_tool".to_string(),
903+
tool_args: serde_json::json!({}),
904+
skill_registry: Some(Arc::new(registry)),
905+
enforce_active_skill_tool_restrictions: true,
906+
tool_context: test_tool_context(),
907+
};
908+
909+
let err = cmd.execute().await.unwrap_err().to_string();
910+
assert!(
911+
err.contains("not allowed by any active skill"),
912+
"legacy mode must deny before tool execution, got: {err}"
913+
);
914+
}
915+
849916
// ========================================================================
850917
// AgentLoop with queue builder tests
851918
// ========================================================================

core/src/agent/tests.rs

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2032,11 +2032,13 @@ fn parallel_write_batch_only_fast_paths_when_gate_would_execute() {
20322032
fn loop_with(
20332033
checker: Option<Arc<dyn PermissionChecker>>,
20342034
skills: Option<Arc<SkillRegistry>>,
2035+
enforce_active_skill_tool_restrictions: bool,
20352036
) -> AgentLoop {
20362037
// `skill_registry` overrides the default builtins where needed.
20372038
let config = AgentConfig {
20382039
permission_checker: checker,
20392040
skill_registry: skills,
2041+
enforce_active_skill_tool_restrictions,
20402042
..Default::default()
20412043
};
20422044
AgentLoop::new(
@@ -2052,32 +2054,37 @@ fn parallel_write_batch_only_fast_paths_when_gate_would_execute() {
20522054

20532055
// Explicit Allow + no restricting skills → fast path is taken.
20542056
assert!(
2055-
loop_with(allow(), None).can_run_parallel_write_batch(&calls),
2057+
loop_with(allow(), None, false).can_run_parallel_write_batch(&calls),
20562058
"explicit Allow with no restrictions → parallel write batch is allowed"
20572059
);
20582060

20592061
// No permission checker → gate resolves to Ask (a Deny without a confirmation
20602062
// manager), so the fast path must be refused.
20612063
assert!(
2062-
!loop_with(None, None).can_run_parallel_write_batch(&calls),
2064+
!loop_with(None, None, false).can_run_parallel_write_batch(&calls),
20632065
"missing checker resolves to Ask/Deny → fast path refused"
20642066
);
20652067

20662068
// Explicit Deny → refused.
20672069
assert!(
2068-
!loop_with(Some(Arc::new(Static(PermissionDecision::Deny))), None)
2069-
.can_run_parallel_write_batch(&calls),
2070+
!loop_with(
2071+
Some(Arc::new(Static(PermissionDecision::Deny))),
2072+
None,
2073+
false
2074+
)
2075+
.can_run_parallel_write_batch(&calls),
20702076
"permission Deny → fast path refused"
20712077
);
20722078

20732079
// Ask → refused (sequential path would need a human round-trip).
20742080
assert!(
2075-
!loop_with(Some(Arc::new(Static(PermissionDecision::Ask))), None)
2081+
!loop_with(Some(Arc::new(Static(PermissionDecision::Ask))), None, false)
20762082
.can_run_parallel_write_batch(&calls),
20772083
"permission Ask → fast path refused"
20782084
);
20792085

2080-
// Allow, but an active skill restriction forbids write_file → refused.
2086+
// By default, active skill restrictions do not block ordinary session
2087+
// tools before the permission/AHP/HITL chain.
20812088
let restricted = SkillRegistry::new();
20822089
restricted.register_unchecked(Arc::new(Skill {
20832090
name: "read-only".to_string(),
@@ -2089,15 +2096,27 @@ fn parallel_write_batch_only_fast_paths_when_gate_would_execute() {
20892096
tags: Vec::new(),
20902097
version: None,
20912098
}));
2099+
let restricted = Arc::new(restricted);
20922100
assert!(
2093-
!loop_with(allow(), Some(Arc::new(restricted))).can_run_parallel_write_batch(&calls),
2094-
"active skill restriction disallowing write_file → fast path refused"
2101+
loop_with(allow(), Some(Arc::clone(&restricted)), false)
2102+
.can_run_parallel_write_batch(&calls),
2103+
"default active skill restriction mode → fast path follows permission allow"
2104+
);
2105+
2106+
// Compatibility mode preserves the legacy refusal.
2107+
assert!(
2108+
!loop_with(allow(), Some(restricted), true).can_run_parallel_write_batch(&calls),
2109+
"legacy active skill restriction mode → fast path refused"
20952110
);
20962111

20972112
// Default builtins do not restrict → still fast-paths with Allow.
20982113
assert!(
2099-
loop_with(allow(), Some(Arc::new(SkillRegistry::with_builtins())))
2100-
.can_run_parallel_write_batch(&calls),
2114+
loop_with(
2115+
allow(),
2116+
Some(Arc::new(SkillRegistry::with_builtins())),
2117+
false
2118+
)
2119+
.can_run_parallel_write_batch(&calls),
21012120
"non-restricting builtins → fast path still allowed"
21022121
);
21032122
}

core/src/agent/tool_execution_runtime.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ impl AgentLoop {
127127
args.clone(),
128128
ctx.clone(),
129129
self.config.skill_registry.clone(),
130+
self.config.enforce_active_skill_tool_restrictions,
130131
);
131132
let rx = queue.submit_by_tool(name, Box::new(command)).await;
132133
match rx.await {

core/src/agent_api.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ pub struct SessionOptions {
163163
pub skill_dirs: Vec<PathBuf>,
164164
/// Optional skill registry for instruction injection
165165
pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
166+
/// Whether active skill `allowed-tools` restrict ordinary session tool calls.
167+
///
168+
/// Defaults to false so ordinary tools continue through permission policy,
169+
/// hooks, HITL, and AHP. Set true to restore the legacy global active-skill
170+
/// restriction behavior.
171+
pub enforce_active_skill_tool_restrictions: Option<bool>,
166172
/// Optional memory store for long-term memory persistence
167173
pub memory_store: Option<Arc<dyn MemoryStore>>,
168174
/// Deferred file memory directory — constructed async in `build_session()`

core/src/agent_api/session_builder.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ pub(super) fn build_agent_session(
155155
planning_mode: opts.planning_mode,
156156
goal_tracking: opts.goal_tracking,
157157
skill_registry: Some(Arc::clone(&effective_registry)),
158+
enforce_active_skill_tool_restrictions: opts
159+
.enforce_active_skill_tool_restrictions
160+
.unwrap_or(base.enforce_active_skill_tool_restrictions),
158161
max_parse_retries: opts.max_parse_retries.unwrap_or(base.max_parse_retries),
159162
tool_timeout_ms: opts.tool_timeout_ms.or(base.tool_timeout_ms),
160163
circuit_breaker_threshold: opts

core/src/agent_api/session_options.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ impl std::fmt::Debug for SessionOptions {
3535
.as_ref()
3636
.map(|r| format!("{} skills", r.len())),
3737
)
38+
.field(
39+
"enforce_active_skill_tool_restrictions",
40+
&self.enforce_active_skill_tool_restrictions,
41+
)
3842
.field("memory_store", &self.memory_store.is_some())
3943
.field("session_store", &self.session_store.is_some())
4044
.field("session_id", &self.session_id)
@@ -210,6 +214,15 @@ impl SessionOptions {
210214
self
211215
}
212216

217+
/// Enable or disable legacy global active-skill `allowed-tools` restrictions.
218+
///
219+
/// The default is disabled: active skills do not block ordinary session
220+
/// tools before the host permission/AHP/HITL approval chain runs.
221+
pub fn with_active_skill_tool_restrictions(mut self, enabled: bool) -> Self {
222+
self.enforce_active_skill_tool_restrictions = Some(enabled);
223+
self
224+
}
225+
213226
/// Add skill directories to scan for skill files (*.md).
214227
/// Merged with any global `skill_dirs` from [`CodeConfig`] at session build time.
215228
pub fn with_skill_dirs(mut self, dirs: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {

core/src/agent_api/session_persistence.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ pub(super) fn apply_persisted_runtime_options(
170170
opts = opts.with_permission_policy(policy);
171171
}
172172
}
173+
if opts.enforce_active_skill_tool_restrictions.is_none() {
174+
opts.enforce_active_skill_tool_restrictions =
175+
Some(data.config.enforce_active_skill_tool_restrictions);
176+
}
173177
if opts.max_parallel_tasks.is_none() {
174178
opts.max_parallel_tasks = data.config.max_parallel_tasks;
175179
}
@@ -271,6 +275,9 @@ async fn build_session_data_snapshot(input: SessionDataSnapshotInput<'_>) -> Ses
271275
queue_config: input.config.queue_config.clone(),
272276
confirmation_policy,
273277
permission_policy: input.config.permission_policy.clone(),
278+
enforce_active_skill_tool_restrictions: input
279+
.config
280+
.enforce_active_skill_tool_restrictions,
274281
max_parallel_tasks: Some(input.config.max_parallel_tasks),
275282
auto_delegation: Some(input.config.auto_delegation.clone()),
276283
parent_id: None,

0 commit comments

Comments
 (0)