Skip to content
Closed
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ path = "src/lib.rs"

[dependencies]
# Internal crates
a3s-common = { version = "0.1", path = "../../common" }
a3s-common = { version = "0.1.1" }
a3s-memory = { version = "0.1.1", path = "../../memory" }
a3s-lane = { version = "0.4", path = "../../lane" }
a3s-search = { version = "1.2.3", path = "../../search", default-features = false, features = ["lightpanda"] }
Expand Down
49 changes: 49 additions & 0 deletions core/src/config/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ fn acl_usize_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<usize> {
}
}

fn acl_u32_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<u32> {
acl_usize_attr(block, keys).map(|value| value.min(u32::MAX as usize) as u32)
}

fn acl_path_list_attr(block: &a3s_acl::Block, keys: &[&str]) -> Option<Vec<PathBuf>> {
let value = acl_attr(block, keys)?;
match value {
Expand Down Expand Up @@ -276,10 +280,55 @@ impl CodeConfig {
model.release_date = Some(release_date);
}
}
"maxTokens" | "max_tokens" | "outputTokens"
| "output_tokens" => {
if let Some(output) = acl_u32_attr(
model_block,
&[
"maxTokens",
"max_tokens",
"outputTokens",
"output_tokens",
],
) {
model.limit.output = output;
}
}
"contextTokens" | "context_tokens" | "maxContextTokens"
| "max_context_tokens" => {
if let Some(context) = acl_u32_attr(
model_block,
&[
"contextTokens",
"context_tokens",
"maxContextTokens",
"max_context_tokens",
],
) {
model.limit.context = context;
}
}
_ => {}
}
}

for limit_block in &model_block.blocks {
if limit_block.name == "limit" {
if let Some(output) = acl_u32_attr(
limit_block,
&["output", "max_tokens", "output_tokens"],
) {
model.limit.output = output;
}
if let Some(context) = acl_u32_attr(
limit_block,
&["context", "context_tokens", "max_context_tokens"],
) {
model.limit.context = context;
}
}
}

provider.models.push(model);
}
}
Expand Down
34 changes: 34 additions & 0 deletions core/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,40 @@ fn test_config_supports_acl_style_provider_labels() {
assert!(config.providers[0].models[0].tool_call);
}

#[test]
fn test_config_parses_acl_model_token_limits() {
let config = CodeConfig::from_acl(
r#"
default_model = "openai/glm-5.1"

providers "openai" {
api_key = "sk-test"
base_url = "http://127.0.0.1:18051/v1"

models "glm-5.1" {
name = "GLM 5.1"
max_tokens = 4096
context_tokens = 32768
}

models "camel-aliases" {
outputTokens = 8192
maxContextTokens = 65536
}
}
"#,
)
.unwrap();

let flat = &config.providers[0].models[0].limit;
assert_eq!(flat.output, 4096);
assert_eq!(flat.context, 32768);

let aliases = &config.providers[0].models[1].limit;
assert_eq!(aliases.output, 8192);
assert_eq!(aliases.context, 65536);
}

#[test]
fn test_config_builder() {
let config = CodeConfig::new()
Expand Down
99 changes: 95 additions & 4 deletions core/src/skills/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub use validator::{
DefaultSkillValidator, SkillValidationError, SkillValidator, ValidationErrorKind,
};

use serde::{Deserialize, Serialize};
use serde::{de, Deserialize, Deserializer, Serialize};
use std::collections::HashSet;
use std::path::Path;

Expand Down Expand Up @@ -101,7 +101,11 @@ pub struct Skill {
pub description: String,

/// Allowed tools (Claude Code format: "Bash(pattern:*), read(*)")
#[serde(default, rename = "allowed-tools")]
#[serde(
default,
rename = "allowed-tools",
deserialize_with = "deserialize_allowed_tools"
)]
pub allowed_tools: Option<String>,

/// Whether to disable model invocation
Expand Down Expand Up @@ -183,11 +187,25 @@ impl Skill {
return permissions;
};

// Parse comma-separated tool permissions
for part in allowed.split(',') {
// Parse Claude-style comma-separated permissions, plus legacy
// whitespace-only tool lists such as "Read Write Edit Bash".
let parts: Vec<&str> = if allowed.contains(',') {
allowed.split(',').collect()
} else {
allowed.split_whitespace().collect()
};
for part in parts {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some(perm) = ToolPermission::parse(part) {
permissions.insert(perm);
} else {
permissions.insert(ToolPermission {
tool: part.to_string(),
pattern: "*".to_string(),
});
}
}

Expand Down Expand Up @@ -218,6 +236,34 @@ impl Skill {
}
}

fn deserialize_allowed_tools<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<serde_yaml::Value>::deserialize(deserializer)?;
match value {
None | Some(serde_yaml::Value::Null) => Ok(None),
Some(serde_yaml::Value::String(s)) => Ok(Some(s)),
Some(serde_yaml::Value::Sequence(items)) => {
let mut tools = Vec::new();
for item in items {
match item {
serde_yaml::Value::String(s) => tools.push(s),
other => {
return Err(de::Error::custom(format!(
"allowed-tools list entries must be strings, got {other:?}"
)));
}
}
}
Ok(Some(tools.join(", ")))
}
Some(other) => Err(de::Error::custom(format!(
"allowed-tools must be a string or a list of strings, got {other:?}"
))),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -270,6 +316,51 @@ You are a test assistant.
assert_eq!(permissions.len(), 3);
}

#[test]
fn test_parse_legacy_whitespace_allowed_tools() {
let skill = Skill {
name: "test".to_string(),
description: "test".to_string(),
allowed_tools: Some("Read Write Edit Bash".to_string()),
disable_model_invocation: false,
kind: SkillKind::Instruction,
content: String::new(),
tags: Vec::new(),
version: None,
};

let permissions = skill.parse_allowed_tools();
assert_eq!(permissions.len(), 4);
assert!(permissions
.iter()
.any(|perm| perm.tool == "Bash" && perm.pattern == "*"));
}

#[test]
fn test_parse_allowed_tools_yaml_list() {
let content = r#"---
name: test-skill
description: A test skill
allowed-tools:
- Read
- Write
- Bash(uv run skills analyze-ci:*)
---
# Instructions
"#;

let skill = Skill::parse(content).unwrap();
assert_eq!(
skill.allowed_tools.as_deref(),
Some("Read, Write, Bash(uv run skills analyze-ci:*)")
);
let permissions = skill.parse_allowed_tools();
assert_eq!(permissions.len(), 3);
assert!(permissions
.iter()
.any(|perm| perm.tool == "Read" && perm.pattern == "*"));
}

#[test]
fn test_is_tool_allowed() {
let skill = Skill {
Expand Down
60 changes: 60 additions & 0 deletions core/src/tools/skill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,16 @@ impl SkillTool {
fn create_skill_permission_policy(skill: &Skill) -> PermissionPolicy {
let permissions = skill.parse_allowed_tools();

if permissions.is_empty() {
return PermissionPolicy {
deny: Vec::new(),
allow: Vec::new(),
ask: Vec::new(),
default_decision: PermissionDecision::Allow,
enabled: true,
};
}

// Convert skill permissions to PermissionRules
let mut allow_rules = Vec::new();
for perm in permissions {
Expand Down Expand Up @@ -453,6 +463,56 @@ mod tests {
);
}

#[test]
fn test_skill_permission_policy_allows_when_unspecified() {
let skill = Skill {
name: "test-skill".to_string(),
description: "Test".to_string(),
allowed_tools: None,
disable_model_invocation: false,
kind: SkillKind::Instruction,
content: String::new(),
tags: Vec::new(),
version: None,
};

let policy = SkillTool::create_skill_permission_policy(&skill);

assert_eq!(
policy.check("bash", &serde_json::json!({"command": "python --version"})),
PermissionDecision::Allow
);
assert_eq!(
policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
PermissionDecision::Allow
);
}

#[test]
fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
let skill = Skill {
name: "test-skill".to_string(),
description: "Test".to_string(),
allowed_tools: Some("Read Write Edit Bash".to_string()),
disable_model_invocation: false,
kind: SkillKind::Instruction,
content: String::new(),
tags: Vec::new(),
version: None,
};

let policy = SkillTool::create_skill_permission_policy(&skill);

assert_eq!(
policy.check("bash", &serde_json::json!({"command": "python --version"})),
PermissionDecision::Allow
);
assert_eq!(
policy.check("grep", &serde_json::json!({"pattern": "x"})),
PermissionDecision::Deny
);
}

#[test]
fn test_skill_args_accepts_documented_shape() {
let args =
Expand Down
Loading