diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 962efe9e0c..b6b9cccd2a 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -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) diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py index fe57d6065c..4fab746207 100644 --- a/api/core/workflow/nodes/agent_v2/validators.py +++ b/api/core/workflow/nodes/agent_v2/validators.py @@ -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( *, diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index c5a16c46b0..4d8443b429 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -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): diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 555b063e59..fff00d80a8 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -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 | diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index 9c91496f68..7fdff7232b 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -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) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index bc26206be1..50bea15fc6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -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( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py index d8a14a8daf..6280d6d289 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -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}}})), + ) diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 5c58a34dcc..8d97ff0c7d 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -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 diff --git a/packages/contracts/generated/api/console/agents/orpc.gen.ts b/packages/contracts/generated/api/console/agents/orpc.gen.ts index 25c1ee7cc7..067dd6fc93 100644 --- a/packages/contracts/generated/api/console/agents/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agents/orpc.gen.ts @@ -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', diff --git a/packages/contracts/generated/api/console/agents/types.gen.ts b/packages/contracts/generated/api/console/agents/types.gen.ts index f1451ab165..1961f45cbf 100644 --- a/packages/contracts/generated/api/console/agents/types.gen.ts +++ b/packages/contracts/generated/api/console/agents/types.gen.ts @@ -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 diff --git a/packages/contracts/generated/api/console/agents/zod.gen.ts b/packages/contracts/generated/api/console/agents/zod.gen.ts index e36018567d..fb24b8c350 100644 --- a/packages/contracts/generated/api/console/agents/zod.gen.ts +++ b/packages/contracts/generated/api/console/agents/zod.gen.ts @@ -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 * diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 09feff2e93..77a3ff7a5e 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -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', diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index c1bccffa9c..6661a5c8be 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -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 diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index ccff2c49f0..ee76b3a446 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -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 */