fix(agent): complete CLI-tool + env shell bootstrap & add composer validation (ENG-367/368) (#37033)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-06-04 13:46:42 +08:00 committed by GitHub
parent 6e3c9597ff
commit 5b5a06136a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 911 additions and 44 deletions

View File

@ -43,6 +43,15 @@ from models.provider_ids import ModelProviderID
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")
_DANGEROUS_ACK_KEYS = (
"dangerous_acknowledged",
"dangerous_accepted",
"risk_accepted",
"approved",
)
from .runtime_feature_manifest import build_runtime_feature_manifest
@ -404,7 +413,11 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
"""Map Agent Soul shell-adjacent fields into the Agent backend shell config."""
sandbox_config = _plain_mapping(agent_soul.sandbox.config)
return DifyShellLayerConfig(
cli_tools=[tool for tool in (_shell_cli_tool(item) for item in agent_soul.tools.cli_tools) if tool is not None],
cli_tools=[
tool
for tool in (_shell_cli_tool(item) for item in agent_soul.tools.cli_tools if _cli_tool_enabled(item))
if tool is not None
],
env=[env for env in (_shell_env_var(item) for item in agent_soul.env.variables) if env is not None],
secret_refs=[
secret for secret in (_shell_secret_ref(item) for item in agent_soul.env.secret_refs) if secret is not None
@ -418,13 +431,26 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
)
def _cli_tool_enabled(item: object) -> bool:
"""A CLI tool is bootstrapped unless explicitly disabled (default is enabled)."""
data = _plain_mapping(item)
if data.get("enabled") is False:
return False
if data.get("pre_authorized") is False or _permission_denied(data):
return False
if _dangerous_without_acknowledgement(data):
return False
return True
def _shell_cli_tool(item: object) -> DifyShellCliToolConfig | None:
data = _plain_mapping(item)
commands: list[str] = []
raw_commands = data.get("install_commands")
if isinstance(raw_commands, list):
commands.extend(str(command) for command in raw_commands if str(command).strip())
for key in ("install_command", "install", "setup_command"):
# ``command`` is the typed AgentCliToolConfig field; the rest are accepted aliases.
for key in ("install_command", "install", "setup_command", "command"):
raw_command = data.get(key)
if isinstance(raw_command, str) and raw_command.strip():
commands.append(raw_command)
@ -468,3 +494,30 @@ def _name_from_mapping(item: Mapping[str, Any]) -> str | None:
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _permission_denied(data: Mapping[str, Any]) -> bool:
permission = data.get("permission")
if isinstance(permission, Mapping):
allowed = permission.get("allowed")
if allowed is False:
return True
status = permission.get("status") or permission.get("state")
if isinstance(status, str) and status in _DENIED_PERMISSION_STATUSES:
return True
for key in ("authorization_status", "permission_status", "status"):
status = data.get(key)
if isinstance(status, str) and status in _DENIED_PERMISSION_STATUSES:
return True
return False
def _dangerous_without_acknowledgement(data: Mapping[str, Any]) -> bool:
dangerous = any(data.get(key) is True for key in _DANGEROUS_FLAG_KEYS)
risk_level = data.get("risk_level")
if isinstance(risk_level, str) and risk_level == "dangerous":
dangerous = True
if not dangerous:
return False
return not any(data.get(key) is True for key in _DANGEROUS_ACK_KEYS)

View File

@ -55,6 +55,9 @@ class WorkflowAgentNodeValidator:
}
)
_SUPPORTED_HUMAN_CONTACT_CHANNELS = frozenset({"email", "slack", "web_app", "webapp", "chat"})
_AGENTIC_TOOL_CONFIG_KEYS = ("agentic_mode", "agenticMode", "agentic")
_MANUAL_TOOL_AGENTIC_STATES = frozenset({"manual", "expert", "expert_zone", "exited"})
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
@classmethod
def validate_draft_workflow(cls, *, session: Session, workflow: Workflow) -> None:
@ -85,6 +88,10 @@ class WorkflowAgentNodeValidator:
continue
cls.validate_binding(session=session, binding=binding, topology=topology)
if require_binding:
for node_id, node_data in cls.iter_tool_nodes(graph):
cls._validate_tool_node_agentic_mode(node_id=node_id, node_data=node_data)
@classmethod
def validate_binding(
cls,
@ -132,6 +139,7 @@ class WorkflowAgentNodeValidator:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} requires Agent Soul model config."
)
cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul)
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
@ -214,6 +222,21 @@ class WorkflowAgentNodeValidator:
if node_data.get("type") == BuiltinNodeTypes.AGENT and str(node_data.get("version")) == "2":
yield node_id, node_data
@staticmethod
def iter_tool_nodes(graph_dict: Mapping[str, Any]) -> Iterator[tuple[str, Mapping[str, Any]]]:
nodes = graph_dict.get("nodes")
if not isinstance(nodes, list):
return
for node in nodes:
if not isinstance(node, Mapping):
continue
node_id = node.get("id")
node_data = node.get("data")
if not isinstance(node_id, str) or not isinstance(node_data, Mapping):
continue
if node_data.get("type") == BuiltinNodeTypes.TOOL:
yield node_id, node_data
@staticmethod
def selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> list[str] | None:
for key in ("selector", "variable_selector", "value_selector"):
@ -306,6 +329,20 @@ class WorkflowAgentNodeValidator:
cli_tool_names: set[str] = set()
for cli_tool in agent_soul.tools.cli_tools:
if not cli_tool.enabled:
continue
if cls._permission_denied(cli_tool.model_dump(mode="python", exclude_none=True, exclude_defaults=True)):
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has unauthorized CLI Tool config."
)
if cli_tool.pre_authorized is False:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has unauthorized CLI Tool config."
)
if cls._dangerous_cli_without_acknowledgement(cli_tool.model_dump(mode="python")):
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has unacknowledged dangerous CLI Tool config."
)
name = cli_tool.get("name") or cli_tool.get("tool_name") or cli_tool.get("label")
if not isinstance(name, str) or not name.strip():
continue
@ -316,6 +353,118 @@ class WorkflowAgentNodeValidator:
)
cli_tool_names.add(normalized_name)
@classmethod
def _validate_agent_soul_env(
cls,
*,
binding: WorkflowAgentNodeBinding,
agent_soul: AgentSoulConfig,
) -> None:
seen_names: set[str] = set()
for env_var in agent_soul.env.variables:
name = env_var.name
if not name:
continue
if name in seen_names:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has duplicate env/secret name {name}."
)
seen_names.add(name)
for secret_ref in agent_soul.env.secret_refs:
name = secret_ref.name
if not name:
continue
if cls._permission_denied(secret_ref.model_dump(mode="python", exclude_none=True, exclude_defaults=True)):
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has unauthorized secret reference {name}."
)
if name in seen_names:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} has duplicate env/secret name {name}."
)
seen_names.add(name)
@classmethod
def _validate_tool_node_agentic_mode(cls, *, node_id: str, node_data: Mapping[str, Any]) -> None:
agentic_config = cls._extract_tool_agentic_config(node_data)
if agentic_config is None or agentic_config is False:
return
if agentic_config is True:
raise WorkflowAgentNodeValidationError(
f"Tool node {node_id} has incomplete agentic mode config for publishing."
)
if not isinstance(agentic_config, Mapping):
raise WorkflowAgentNodeValidationError(f"Tool node {node_id} has invalid agentic mode config.")
if agentic_config.get("enabled") is False:
return
if cls._permission_denied(agentic_config):
raise WorkflowAgentNodeValidationError(f"Tool node {node_id} has unauthorized agentic mode config.")
if agentic_config.get("complete") is False:
raise WorkflowAgentNodeValidationError(
f"Tool node {node_id} has incomplete agentic mode config for publishing."
)
state = agentic_config.get("state") or agentic_config.get("mode")
if isinstance(state, str) and state in cls._MANUAL_TOOL_AGENTIC_STATES:
return
if cls._extract_agentic_parameter_draft(agentic_config) is None and not cls._tool_node_has_manual_parameters(
node_data
):
raise WorkflowAgentNodeValidationError(
f"Tool node {node_id} has incomplete agentic mode config for publishing."
)
@classmethod
def _extract_tool_agentic_config(cls, node_data: Mapping[str, Any]) -> object | None:
for key in cls._AGENTIC_TOOL_CONFIG_KEYS:
if key in node_data:
return node_data[key]
return None
@staticmethod
def _extract_agentic_parameter_draft(agentic_config: Mapping[str, Any]) -> Mapping[str, Any] | None:
for key in ("parameter_draft", "parameters_draft", "draft_parameters", "inferred_parameters", "parameters"):
value = agentic_config.get(key)
if isinstance(value, Mapping) and value:
return value
return None
@staticmethod
def _tool_node_has_manual_parameters(node_data: Mapping[str, Any]) -> bool:
for key in ("tool_parameters", "tool_configurations"):
value = node_data.get(key)
if isinstance(value, Mapping) and value:
return True
return False
@classmethod
def _permission_denied(cls, value: Mapping[str, Any]) -> bool:
permission = value.get("permission")
if isinstance(permission, Mapping):
allowed = permission.get("allowed")
if allowed is False:
return True
status = permission.get("status") or permission.get("state")
if isinstance(status, str) and status in cls._DENIED_PERMISSION_STATUSES:
return True
status = value.get("permission_status") or value.get("authorization_status")
return isinstance(status, str) and status in cls._DENIED_PERMISSION_STATUSES
@staticmethod
def _dangerous_cli_without_acknowledgement(value: Mapping[str, Any]) -> bool:
dangerous = any(value.get(key) is True for key in ("dangerous", "dangerous_command", "requires_confirmation"))
risk_level = value.get("risk_level")
if isinstance(risk_level, str) and risk_level == "dangerous":
dangerous = True
if not dangerous:
return False
return not any(
value.get(key) is True
for key in ("dangerous_acknowledged", "dangerous_accepted", "risk_accepted", "approved")
)
@staticmethod
def _validate_file_ref(
*,

View File

@ -26,6 +26,32 @@ class DeclaredOutputType(StrEnum):
FILE = "file"
class AgentCliToolAuthorizationStatus(StrEnum):
"""Authorization state for Agent-scoped CLI tools.
Missing status keeps backward compatibility with draft rows and CLI tools that
do not need pre-authorization. Explicit denied-like states are blocked by the
composer/publish validators and skipped by runtime request builders.
"""
AUTHORIZED = "authorized"
PRE_AUTHORIZED = "pre_authorized"
ALLOWED = "allowed"
NOT_REQUIRED = "not_required"
UNAUTHORIZED = "unauthorized"
PENDING = "pending"
DENIED = "denied"
FORBIDDEN = "forbidden"
class AgentCliToolRiskLevel(StrEnum):
"""Risk marker for CLI tool bootstrap commands."""
SAFE = "safe"
DANGEROUS = "dangerous"
UNKNOWN = "unknown"
class OutputErrorStrategy(StrEnum):
"""Per-output failure handling strategy.
@ -80,6 +106,13 @@ class AgentCliToolConfig(AgentFlexibleConfig):
name: str | None = Field(default=None, max_length=255)
description: str | None = None
command: str | None = None
invoke_metadata: dict[str, Any] = Field(default_factory=dict)
pre_authorized: bool | None = None
authorization_status: AgentCliToolAuthorizationStatus | None = None
permission: dict[str, Any] = Field(default_factory=dict)
dangerous: bool = False
dangerous_acknowledged: bool = False
risk_level: AgentCliToolRiskLevel | None = None
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
@ -121,6 +154,8 @@ class AgentSecretRefConfig(AgentFlexibleConfig):
type: str | None = Field(default=None, max_length=64)
id: str | None = Field(default=None, max_length=255)
provider: str | None = Field(default=None, max_length=255)
permission: dict[str, Any] = Field(default_factory=dict)
permission_status: str | None = Field(default=None, max_length=64)
class AgentSandboxProviderConfig(AgentFlexibleConfig):

View File

@ -10919,14 +10919,41 @@ default (the config form sends the full desired feature state on save).
| suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No |
| text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No |
#### AgentCliToolAuthorizationStatus
Authorization state for Agent-scoped CLI tools.
Missing status keeps backward compatibility with draft rows and CLI tools that
do not need pre-authorization. Explicit denied-like states are blocked by the
composer/publish validators and skipped by runtime request builders.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentCliToolAuthorizationStatus | string | Authorization state for Agent-scoped CLI tools. Missing status keeps backward compatibility with draft rows and CLI tools that do not need pre-authorization. Explicit denied-like states are blocked by the composer/publish validators and skipped by runtime request builders. | |
#### AgentCliToolConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| authorization_status | [AgentCliToolAuthorizationStatus](#agentclitoolauthorizationstatus) | | No |
| command | string | | No |
| dangerous | boolean | | No |
| dangerous_acknowledged | boolean | | No |
| description | string | | No |
| enabled | boolean | | No |
| invoke_metadata | object | | No |
| name | string | | No |
| permission | object | | No |
| pre_authorized | boolean | | No |
| risk_level | [AgentCliToolRiskLevel](#agentclitoolrisklevel) | | No |
#### AgentCliToolRiskLevel
Risk marker for CLI tool bootstrap commands.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentCliToolRiskLevel | string | Risk marker for CLI tool bootstrap commands. | |
#### AgentComposerAgentResponse
@ -11324,6 +11351,8 @@ Visibility and lifecycle scope of an Agent record.
| ---- | ---- | ----------- | -------- |
| id | string | | No |
| name | string | | No |
| permission | object | | No |
| permission_status | string | | No |
| provider | string | | No |
| type | string | | No |

View File

@ -1,3 +1,4 @@
import re
from typing import Any
from pydantic import ValidationError
@ -19,6 +20,21 @@ _PLAINTEXT_SECRET_KEYS = {
"secret_key",
}
# Env/secret names become shell ``export`` identifiers in the sandbox bootstrap, so
# they must be valid shell identifiers. Validating here fails fast at composer save
# with a friendly error instead of at run time in the agent backend shell layer.
_SHELL_ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_CLI_TOOL_NAME_KEYS = ("name", "tool_name", "label")
_CLI_TOOL_COMMAND_KEYS = ("command", "install_command", "install", "setup_command")
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")
_DANGEROUS_ACK_KEYS = (
"dangerous_acknowledged",
"dangerous_accepted",
"risk_accepted",
"approved",
)
class ComposerConfigValidator:
@classmethod
@ -33,7 +49,9 @@ class ComposerConfigValidator:
@classmethod
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:
cls._reject_plaintext_secrets(agent_soul.model_dump(mode="json"), path="agent_soul")
dumped = agent_soul.model_dump(mode="json")
cls._reject_plaintext_secrets(dumped, path="agent_soul")
cls._validate_shell_config(dumped)
@classmethod
def validate_node_job(cls, node_job: WorkflowNodeJobConfig) -> None:
@ -57,6 +75,53 @@ class ComposerConfigValidator:
cls.validate_node_job(config)
return config
@classmethod
def _validate_shell_config(cls, soul: dict[str, Any]) -> None:
"""Fail fast on shell env/secret/CLI config the sandbox would otherwise reject at run time."""
env = soul.get("env") or {}
seen_env_names: set[str] = set()
for section in ("variables", "secret_refs"):
entries = env.get(section)
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict):
continue
raw_name = entry.get("name")
if not isinstance(raw_name, str) or not raw_name.strip():
# Unnamed draft rows are tolerated; only named entries are bound to the shell.
continue
name = raw_name.strip()
if not _SHELL_ENV_NAME_PATTERN.fullmatch(name):
raise InvalidComposerConfigError(
f"env/secret name '{name}' must be a valid shell identifier (^[A-Za-z_][A-Za-z0-9_]*$)."
)
if section == "secret_refs" and cls._permission_denied(entry):
raise InvalidComposerConfigError(f"secret reference '{name}' is not authorized for this agent.")
if name in seen_env_names:
raise InvalidComposerConfigError(
f"duplicate env/secret name '{name}': environment variables and secret references "
"share the shell namespace."
)
seen_env_names.add(name)
tools = soul.get("tools") or {}
cli_tools = tools.get("cli_tools")
if isinstance(cli_tools, list):
for entry in cli_tools:
if not isinstance(entry, dict) or entry.get("enabled") is False:
continue
has_name = any(isinstance(entry.get(key), str) and entry[key].strip() for key in _CLI_TOOL_NAME_KEYS)
has_command = cls._has_install_command(entry)
if not has_name and not has_command:
raise InvalidComposerConfigError("an enabled CLI tool must declare a name or an install command.")
if cls._permission_denied(entry) or entry.get("pre_authorized") is False:
raise InvalidComposerConfigError("an enabled CLI tool is not authorized for runtime bootstrap.")
if cls._dangerous_without_acknowledgement(entry):
raise InvalidComposerConfigError(
"a dangerous CLI tool command must be explicitly acknowledged before save."
)
@classmethod
def _reject_plaintext_secrets(cls, value: Any, *, path: str) -> None:
if isinstance(value, dict):
@ -69,3 +134,39 @@ class ComposerConfigValidator:
elif isinstance(value, list):
for index, nested in enumerate(value):
cls._reject_plaintext_secrets(nested, path=f"{path}[{index}]")
@classmethod
def _has_install_command(cls, entry: dict[str, Any]) -> bool:
raw_commands = entry.get("install_commands")
if isinstance(raw_commands, list) and any(
isinstance(command, str) and command.strip() for command in raw_commands
):
return True
return any(isinstance(entry.get(key), str) and entry[key].strip() for key in _CLI_TOOL_COMMAND_KEYS)
@classmethod
def _permission_denied(cls, entry: dict[str, Any]) -> bool:
permission = entry.get("permission")
if isinstance(permission, dict):
allowed = permission.get("allowed")
if allowed is False:
return True
status = permission.get("status") or permission.get("state")
if isinstance(status, str) and status in _DENIED_PERMISSION_STATUSES:
return True
for key in ("authorization_status", "permission_status", "status"):
status = entry.get(key)
if isinstance(status, str) and status in _DENIED_PERMISSION_STATUSES:
return True
return False
@classmethod
def _dangerous_without_acknowledgement(cls, entry: dict[str, Any]) -> bool:
dangerous = any(entry.get(key) is True for key in _DANGEROUS_FLAG_KEYS)
risk_level = entry.get("risk_level")
if isinstance(risk_level, str) and risk_level == "dangerous":
dangerous = True
if not dangerous:
return False
return not any(entry.get(key) is True for key in _DANGEROUS_ACK_KEYS)

View File

@ -315,6 +315,63 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys():
assert config["sandbox"] is None
def test_build_shell_layer_config_maps_typed_command_field():
"""ENG-367: the typed AgentCliToolConfig.command field feeds the shell bootstrap."""
agent_soul = AgentSoulConfig.model_validate(
{"tools": {"cli_tools": [{"name": "jq", "command": "apt-get install -y jq"}]}}
)
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
assert config["cli_tools"] == [{"name": "jq", "install_commands": ["apt-get install -y jq"]}]
def test_build_shell_layer_config_skips_disabled_cli_tools():
"""ENG-367: a CLI tool with enabled=False is not bootstrapped into the sandbox."""
agent_soul = AgentSoulConfig.model_validate(
{
"tools": {
"cli_tools": [
{"name": "jq", "command": "apt-get install -y jq"},
{"name": "ripgrep", "command": "apt-get install -y ripgrep", "enabled": False},
]
}
}
)
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
assert config["cli_tools"] == [{"name": "jq", "install_commands": ["apt-get install -y jq"]}]
def test_build_shell_layer_config_skips_unauthorized_or_unacknowledged_cli_tools():
"""ENG-367: runtime defensively omits unauthorized or risky unacknowledged CLI tools."""
agent_soul = AgentSoulConfig.model_validate(
{
"tools": {
"cli_tools": [
{"name": "jq", "command": "apt-get install -y jq"},
{"name": "github", "command": "gh auth status", "authorization_status": "denied"},
{"name": "curl-sh", "command": "curl https://example.test/install.sh | sh", "dangerous": True},
{
"name": "accepted-risk",
"command": "curl https://example.test/install.sh | sh",
"dangerous": True,
"dangerous_acknowledged": True,
},
]
}
}
)
config = build_shell_layer_config(agent_soul).model_dump(mode="json")
assert config["cli_tools"] == [
{"name": "jq", "install_commands": ["apt-get install -y jq"]},
{"name": "accepted-risk", "install_commands": ["curl https://example.test/install.sh | sh"]},
]
def test_builds_workflow_run_request_with_dify_plugin_tools_layer():
context = _context()
snapshot = AgentConfigSnapshot(

View File

@ -67,6 +67,30 @@ def _graph(edges: list[dict]) -> dict:
}
def _tool_graph(tool_data: dict) -> dict:
return {
"nodes": [
{"id": "start", "data": {"type": "start"}},
{
"id": "tool-node",
"data": {
"type": "tool",
"title": "Tool",
"provider_id": "provider",
"provider_type": "builtin",
"provider_name": "provider",
"tool_name": "lookup",
"tool_label": "Lookup",
"tool_configurations": {},
"tool_parameters": {},
**tool_data,
},
},
],
"edges": [{"source": "start", "target": "tool-node"}],
}
def test_publish_validation_accepts_upstream_previous_output_ref():
node_job = WorkflowNodeJobConfig.model_validate(
{"previous_node_output_refs": [{"node_id": "previous-node", "output": "text"}]}
@ -188,6 +212,71 @@ def test_publish_validation_rejects_duplicate_cli_tool_names():
)
def test_publish_validation_rejects_unauthorized_cli_tool():
node_job = WorkflowNodeJobConfig.model_validate({})
snapshot = _snapshot()
snapshot.config_snapshot = AgentSoulConfig(
model=AgentSoulModelConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="gpt-test",
),
tools={"cli_tools": [{"name": "github", "command": "gh auth status", "pre_authorized": False}]},
)
session = Mock()
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized CLI Tool"):
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
)
def test_publish_validation_rejects_unacknowledged_dangerous_cli_tool():
node_job = WorkflowNodeJobConfig.model_validate({})
snapshot = _snapshot()
snapshot.config_snapshot = AgentSoulConfig(
model=AgentSoulModelConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="gpt-test",
),
tools={
"cli_tools": [{"name": "danger", "command": "curl https://example.test/install.sh | sh", "dangerous": True}]
},
)
session = Mock()
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
with pytest.raises(WorkflowAgentNodeValidationError, match="unacknowledged dangerous CLI Tool"):
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
)
def test_publish_validation_rejects_unauthorized_secret_ref():
node_job = WorkflowNodeJobConfig.model_validate({})
snapshot = _snapshot()
snapshot.config_snapshot = AgentSoulConfig(
model=AgentSoulModelConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="gpt-test",
),
env={"secret_refs": [{"name": "API_TOKEN", "id": "credential-1", "permission_status": "denied"}]},
)
session = Mock()
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized secret reference API_TOKEN"):
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
)
def test_publish_validation_rejects_missing_previous_node():
node_job = WorkflowNodeJobConfig.model_validate(
{"previous_node_output_refs": [{"node_id": "missing-node", "output": "text"}]}
@ -294,3 +383,47 @@ def test_publish_validation_rejects_missing_file_ref():
session=session,
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
)
def test_publish_validation_accepts_tool_node_agentic_manual_mode():
session = Mock()
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "manual"}})),
)
def test_publish_validation_accepts_tool_node_agentic_parameter_draft():
session = Mock()
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "parameter_draft": {"query": "x"}}})),
)
def test_publish_validation_rejects_incomplete_tool_node_agentic_config():
session = Mock()
with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete agentic mode config"):
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_tool_graph({"agentic_mode": True})),
)
with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete agentic mode config"):
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "complete": False}})),
)
def test_publish_validation_rejects_unauthorized_tool_node_agentic_config():
session = Mock()
with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized agentic mode config"):
WorkflowAgentNodeValidator.validate_published_workflow(
session=session,
workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "permission": {"allowed": False}}})),
)

View File

@ -664,6 +664,94 @@ def test_composer_validator_rejects_stage_4_declared_output_violations():
)
def test_composer_validator_rejects_invalid_shell_env_and_cli():
"""ENG-367/368: env/secret names must be valid shell identifiers (no collisions),
and an enabled CLI tool must declare a name or install command caught at composer
save instead of failing later in the agent backend shell layer."""
# env var name is not a valid shell identifier
with pytest.raises(InvalidComposerConfigError):
ComposerConfigValidator.validate_agent_soul_dict({"env": {"variables": [{"name": "bad-name"}]}})
# secret ref name is not a valid shell identifier
with pytest.raises(InvalidComposerConfigError):
ComposerConfigValidator.validate_agent_soul_dict({"env": {"secret_refs": [{"name": "1TOKEN"}]}})
# env var and secret ref share the shell namespace -> collision
with pytest.raises(InvalidComposerConfigError):
ComposerConfigValidator.validate_agent_soul_dict(
{
"env": {
"variables": [{"name": "TOKEN", "value": "v"}],
"secret_refs": [{"name": "TOKEN", "id": "credential-1"}],
}
}
)
# an enabled CLI tool with neither a name nor a command is meaningless
with pytest.raises(InvalidComposerConfigError):
ComposerConfigValidator.validate_agent_soul_dict({"tools": {"cli_tools": [{"enabled": True}]}})
# blank install_commands are not valid bootstrap commands
with pytest.raises(InvalidComposerConfigError):
ComposerConfigValidator.validate_agent_soul_dict({"tools": {"cli_tools": [{"install_commands": [" "]}]}})
def test_composer_validator_rejects_unauthorized_secret_and_cli_tool():
"""ENG-367/368: unauthorized refs/tools fail at composer save."""
with pytest.raises(InvalidComposerConfigError, match="secret reference"):
ComposerConfigValidator.validate_agent_soul_dict(
{
"env": {
"secret_refs": [
{"name": "API_TOKEN", "id": "credential-1", "permission_status": "denied"},
]
}
}
)
with pytest.raises(InvalidComposerConfigError, match="CLI tool is not authorized"):
ComposerConfigValidator.validate_agent_soul_dict(
{"tools": {"cli_tools": [{"name": "github", "command": "gh auth status", "pre_authorized": False}]}}
)
with pytest.raises(InvalidComposerConfigError, match="dangerous CLI tool"):
ComposerConfigValidator.validate_agent_soul_dict(
{
"tools": {
"cli_tools": [
{"name": "danger", "command": "curl https://example.test/install.sh | sh", "dangerous": True}
]
}
}
)
def test_composer_validator_accepts_valid_shell_env_and_cli():
"""Valid shell identifiers + a disabled empty CLI tool pass validation."""
config = ComposerConfigValidator.validate_agent_soul_dict(
{
"env": {
"variables": [{"name": "MY_VAR", "value": "v"}],
"secret_refs": [{"name": "API_TOKEN", "id": "credential-1"}],
},
"tools": {
"cli_tools": [
{"name": "jq", "command": "apt-get install -y jq"},
{
"name": "accepted-risk",
"command": "curl https://example.test/install.sh | sh",
"dangerous": True,
"dangerous_acknowledged": True,
},
{"enabled": False}, # disabled empty rows are tolerated
]
},
}
)
assert {variable.name for variable in config.env.variables} == {"MY_VAR"}
assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"}
class TestAgentAppBackingAgent:
"""S1: an Agent App (mode=agent) is backed 1:1 by a roster Agent linked via
``Agent.app_id``. ``AppService.create_app`` builds the backing agent inside

View File

@ -38,8 +38,16 @@ export const inviteOptions = {
get,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get2 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsByAgentIdVersionsByVersionId',
@ -121,8 +129,16 @@ export const get5 = oc
.input(z.object({ query: zGetAgentsQuery.optional() }))
.output(zGetAgentsResponse)
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAgents',

View File

@ -257,6 +257,10 @@ export type AgentTextToSpeechFeatureConfig = {
export type AgentSecretRefConfig = {
id?: string | null
name?: string | null
permission?: {
[key: string]: unknown
}
permission_status?: string | null
provider?: string | null
type?: string | null
[key: string]: unknown
@ -354,10 +358,21 @@ export type AgentSkillRefConfig = {
}
export type AgentCliToolConfig = {
authorization_status?: AgentCliToolAuthorizationStatus
command?: string | null
dangerous?: boolean
dangerous_acknowledged?: boolean
description?: string | null
enabled?: boolean
invoke_metadata?: {
[key: string]: unknown
}
name?: string | null
permission?: {
[key: string]: unknown
}
pre_authorized?: boolean | null
risk_level?: AgentCliToolRiskLevel
[key: string]: unknown
}
@ -390,6 +405,18 @@ export type AgentModelResponseFormatConfig = {
[key: string]: unknown
}
export type AgentCliToolAuthorizationStatus
= | 'allowed'
| 'authorized'
| 'denied'
| 'forbidden'
| 'not_required'
| 'pending'
| 'pre_authorized'
| 'unauthorized'
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
export type AgentSoulDifyToolCredentialRef = {
id?: string | null
provider?: string | null

View File

@ -218,6 +218,8 @@ export const zAgentTextToSpeechFeatureConfig = z.object({
export const zAgentSecretRefConfig = z.object({
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
permission: z.record(z.string(), z.unknown()).optional(),
permission_status: z.string().max(64).nullish(),
provider: z.string().max(255).nullish(),
type: z.string().max(64).nullish(),
})
@ -378,16 +380,6 @@ export const zAgentSoulSkillsFilesConfig = z.object({
skills: z.array(zAgentSkillRefConfig).optional(),
})
/**
* AgentCliToolConfig
*/
export const zAgentCliToolConfig = z.object({
command: z.string().nullish(),
description: z.string().nullish(),
enabled: z.boolean().optional().default(true),
name: z.string().max(255).nullish(),
})
/**
* AgentModelResponseFormatConfig
*/
@ -430,6 +422,50 @@ export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({
prompt: z.string().nullish(),
})
/**
* AgentCliToolAuthorizationStatus
*
* Authorization state for Agent-scoped CLI tools.
*
* Missing status keeps backward compatibility with draft rows and CLI tools that
* do not need pre-authorization. Explicit denied-like states are blocked by the
* composer/publish validators and skipped by runtime request builders.
*/
export const zAgentCliToolAuthorizationStatus = z.enum([
'allowed',
'authorized',
'denied',
'forbidden',
'not_required',
'pending',
'pre_authorized',
'unauthorized',
])
/**
* AgentCliToolRiskLevel
*
* Risk marker for CLI tool bootstrap commands.
*/
export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown'])
/**
* AgentCliToolConfig
*/
export const zAgentCliToolConfig = z.object({
authorization_status: zAgentCliToolAuthorizationStatus.optional(),
command: z.string().nullish(),
dangerous: z.boolean().optional().default(false),
dangerous_acknowledged: z.boolean().optional().default(false),
description: z.string().nullish(),
enabled: z.boolean().optional().default(true),
invoke_metadata: z.record(z.string(), z.unknown()).optional(),
name: z.string().max(255).nullish(),
permission: z.record(z.string(), z.unknown()).optional(),
pre_authorized: z.boolean().nullish(),
risk_level: zAgentCliToolRiskLevel.optional(),
})
/**
* AgentSoulDifyToolCredentialRef
*

View File

@ -789,8 +789,16 @@ export const advancedChat = {
workflows: workflows2,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get4 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdAgentComposerCandidates',
@ -804,8 +812,16 @@ export const candidates = {
get: get4,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post9 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdAgentComposerValidate',
@ -824,8 +840,16 @@ export const validate = {
post: post9,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get5 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdAgentComposer',
@ -835,8 +859,16 @@ export const get5 = oc
.input(z.object({ params: zGetAppsByAppIdAgentComposerPath }))
.output(zGetAppsByAppIdAgentComposerResponse)
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const put = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'PUT',
operationId: 'putAppsByAppIdAgentComposer',
@ -3685,8 +3717,16 @@ export const loop2 = {
nodes: nodes6,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get62 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidates',
@ -3702,8 +3742,16 @@ export const candidates2 = {
get: get62,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post48 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpact',
@ -3722,8 +3770,16 @@ export const impact = {
post: post48,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post49 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRoster',
@ -3742,8 +3798,16 @@ export const saveToRoster = {
post: post49,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post50 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidate',
@ -3762,8 +3826,16 @@ export const validate2 = {
post: post50,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get63 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposer',
@ -3773,8 +3845,16 @@ export const get63 = oc
.input(z.object({ params: zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerPath }))
.output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse)
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const put5 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'PUT',
operationId: 'putAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposer',

View File

@ -1628,10 +1628,21 @@ export type WorkflowPreviousNodeOutputRef = {
export type DeclaredOutputType = 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string'
export type AgentCliToolConfig = {
authorization_status?: AgentCliToolAuthorizationStatus
command?: string | null
dangerous?: boolean
dangerous_acknowledged?: boolean
description?: string | null
enabled?: boolean
invoke_metadata?: {
[key: string]: unknown
}
name?: string | null
permission?: {
[key: string]: unknown
}
pre_authorized?: boolean | null
risk_level?: AgentCliToolRiskLevel
[key: string]: unknown
}
@ -1767,6 +1778,10 @@ export type DeclaredOutputFileConfig = {
export type AgentSecretRefConfig = {
id?: string | null
name?: string | null
permission?: {
[key: string]: unknown
}
permission_status?: string | null
provider?: string | null
type?: string | null
[key: string]: unknown
@ -1854,6 +1869,18 @@ export type AgentSoulDifyToolConfig = {
tool_name: string
}
export type AgentCliToolAuthorizationStatus
= | 'allowed'
| 'authorized'
| 'denied'
| 'forbidden'
| 'not_required'
| 'pending'
| 'pre_authorized'
| 'unauthorized'
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
export type AgentModerationIoConfig = {
enabled?: boolean
preset_response?: string | null

View File

@ -1690,16 +1690,6 @@ export const zAgentComposerNodeJobCandidatesResponse = z.object({
previous_node_outputs: z.array(zWorkflowPreviousNodeOutputRef).optional(),
})
/**
* AgentCliToolConfig
*/
export const zAgentCliToolConfig = z.object({
command: z.string().nullish(),
description: z.string().nullish(),
enabled: z.boolean().optional().default(true),
name: z.string().max(255).nullish(),
})
/**
* AgentComposerDifyToolCandidateResponse
*/
@ -1732,27 +1722,6 @@ export const zAgentSkillRefConfig = z.object({
path: z.string().nullish(),
})
/**
* AgentComposerSoulCandidatesResponse
*/
export const zAgentComposerSoulCandidatesResponse = z.object({
cli_tools: z.array(zAgentCliToolConfig).optional(),
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
human_contacts: z.array(zAgentHumanContactConfig).optional(),
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
skills_files: z.array(zAgentSkillRefConfig).optional(),
})
/**
* AgentComposerCandidatesResponse
*/
export const zAgentComposerCandidatesResponse = z.object({
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
capabilities: zComposerCandidateCapabilities.optional(),
variant: zComposerVariant,
})
/**
* SimpleModelConfig
*/
@ -1978,6 +1947,8 @@ export const zDeclaredOutputFileConfig = z.object({
export const zAgentSecretRefConfig = z.object({
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
permission: z.record(z.string(), z.unknown()).optional(),
permission_status: z.string().max(64).nullish(),
provider: z.string().max(255).nullish(),
type: z.string().max(64).nullish(),
})
@ -2114,6 +2085,71 @@ export const zWorkflowNodeJobMetadata = z.object({
file_refs: z.array(zAgentFileRefConfig).nullish(),
})
/**
* AgentCliToolAuthorizationStatus
*
* Authorization state for Agent-scoped CLI tools.
*
* Missing status keeps backward compatibility with draft rows and CLI tools that
* do not need pre-authorization. Explicit denied-like states are blocked by the
* composer/publish validators and skipped by runtime request builders.
*/
export const zAgentCliToolAuthorizationStatus = z.enum([
'allowed',
'authorized',
'denied',
'forbidden',
'not_required',
'pending',
'pre_authorized',
'unauthorized',
])
/**
* AgentCliToolRiskLevel
*
* Risk marker for CLI tool bootstrap commands.
*/
export const zAgentCliToolRiskLevel = z.enum(['dangerous', 'safe', 'unknown'])
/**
* AgentCliToolConfig
*/
export const zAgentCliToolConfig = z.object({
authorization_status: zAgentCliToolAuthorizationStatus.optional(),
command: z.string().nullish(),
dangerous: z.boolean().optional().default(false),
dangerous_acknowledged: z.boolean().optional().default(false),
description: z.string().nullish(),
enabled: z.boolean().optional().default(true),
invoke_metadata: z.record(z.string(), z.unknown()).optional(),
name: z.string().max(255).nullish(),
permission: z.record(z.string(), z.unknown()).optional(),
pre_authorized: z.boolean().nullish(),
risk_level: zAgentCliToolRiskLevel.optional(),
})
/**
* AgentComposerSoulCandidatesResponse
*/
export const zAgentComposerSoulCandidatesResponse = z.object({
cli_tools: z.array(zAgentCliToolConfig).optional(),
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
human_contacts: z.array(zAgentHumanContactConfig).optional(),
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
skills_files: z.array(zAgentSkillRefConfig).optional(),
})
/**
* AgentComposerCandidatesResponse
*/
export const zAgentComposerCandidatesResponse = z.object({
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
capabilities: zComposerCandidateCapabilities.optional(),
variant: zComposerVariant,
})
/**
* AgentModerationIOConfig
*/