fix: handle Windows path separators in Policies ToolGuard Guard mode (#13727) (#13751)

* fix: handle Windows path separators in Policies ToolGuard Guard mode (#13727)

Guard mode read generated guard files back from the node template with
str(file_name), where file_name is a pathlib.Path from the toolguard
result model. On Windows str() yields backslashes, but the files are
stored (by sync_generated_guard_code_inputs) under their POSIX relative
path via Path.as_posix() (forward slashes). Every lookup therefore missed
on Windows, attrs.get(...) returned None, and None["value"] crashed with
"'NoneType' object is not subscriptable".

- Add PoliciesComponent._template_field_key() to normalize a file name to
  the POSIX key the sync step writes; route all reads in
  make_toolguard_result() through it so lookups match on every platform.
- Raise a clear "re-run Generate" error when a generated field is missing
  instead of subscripting None.
- Relax validate_before_generate(): api_key is optional (required=False,
  advanced=True) and credentials can come from the model connection/env,
  so only the model selection is required. Fixes the spurious
  "model or api_key cannot be empty!" block.
- Regenerate the bundled component index for the updated source.

The separate "Generate emits the pass # FIXME stub" and Windows
charmap-decode defects are in the upstream toolguard package (latest
0.2.19) and are out of scope here; this component is ready to consume a
fixed toolguard release once available.

Fixes #13727

* [autofix.ci] apply automated fixes

* Update templates

* [autofix.ci] apply automated fixes

* fix: address review feedback on Policies component

- Add `str | Path` type hints to `_template_field_key` and the inner
  `read_content` helper (mypy compliance).
- Give the empty-template `ValueError` in `make_toolguard_result` a
  descriptive message instead of a bare raise.
- Regenerate the bundled component index for the updated source.

* Update component_index.json

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Eric Hare
2026-06-19 16:15:42 -07:00
committed by GitHub
parent 9bd47b601f
commit f5ba8f54ca
6 changed files with 209 additions and 21 deletions

View File

@ -703,7 +703,7 @@
},
{
"name": "cryptography",
"version": "49.0.0"
"version": "48.0.1"
},
{
"name": "langchain_chroma",

View File

@ -464,7 +464,7 @@
},
{
"name": "OpenDsStar",
"version": null
"version": "1.0.26"
}
],
"total_dependencies": 4

View File

@ -1620,7 +1620,7 @@
},
{
"name": "cryptography",
"version": "49.0.0"
"version": "48.0.1"
},
{
"name": "langchain_chroma",

View File

@ -265,7 +265,7 @@ async def test_generate_mode_validation_errors(mock_component):
# Test missing model
mock_component.in_tools = [MagicMock()]
mock_component.model = None
with pytest.raises(ValueError, match="model or api_key cannot be empty"):
with pytest.raises(ValueError, match="model cannot be empty"):
await mock_component.guard_tools()
# # Test non-recommended model
@ -368,4 +368,145 @@ async def test_verify_cached_guards_error_messages(mock_component):
assert "Unexpected error" in str(exc_info.value)
def test_template_field_key_normalizes_separators():
"""Node-template keys are POSIX; OS-separator file names must normalize to match.
Regression for #13727: on Windows the toolguard result stores ``file_name`` as
a Path whose ``str()`` uses backslashes, while ``sync_generated_guard_code_inputs``
keys the template by ``Path.as_posix()`` (forward slashes). The lookup must
normalize so it matches on every platform.
"""
# Forward-slash input is unchanged.
assert PoliciesComponent._template_field_key("proj/fetch_content/guard.py") == "proj/fetch_content/guard.py"
# Backslash input (what str(WindowsPath(...)) yields) normalizes to forward slashes.
assert PoliciesComponent._template_field_key("proj\\fetch_content\\guard.py") == "proj/fetch_content/guard.py"
# Path inputs normalize too.
assert PoliciesComponent._template_field_key(Path("proj/fetch_content/guard.py")) == "proj/fetch_content/guard.py"
# Single-segment names (e.g. result.json) are unaffected.
assert PoliciesComponent._template_field_key("result.json") == "result.json"
def _fake_file_twin(file_name):
"""A stand-in for toolguard's FileTwin: a ``file_name`` plus assignable ``content``."""
from types import SimpleNamespace
return SimpleNamespace(file_name=file_name, content=None)
def test_make_toolguard_result_reads_posix_keyed_fields(mock_component):
"""make_toolguard_result must read fields the sync step keyed by POSIX path.
Regression for #13727 (the 'NoneType' object is not subscriptable crash on
Windows). The toolguard result hands back ``file_name`` values using OS
separators (here simulated with backslashes, exactly as ``str(WindowsPath)``
produces), while the node template is keyed by forward-slash POSIX paths. The
read path must normalize and match instead of returning ``None``.
"""
from types import SimpleNamespace
# file_name values as they arrive from the result model on Windows (backslashes).
types_fn = "proj\\proj_types.py"
api_fn = "proj\\proj_api.py"
impl_fn = "proj\\proj_api_impl.py"
guard_fn = "proj\\fetch_content\\guard.py"
item_fn = "proj\\fetch_content\\guard_allowed_url_domains.py"
fake_result = SimpleNamespace(
domain=SimpleNamespace(
app_types=_fake_file_twin(types_fn),
app_api=_fake_file_twin(api_fn),
app_api_impl=_fake_file_twin(impl_fn),
),
tools={
"fetch_content": SimpleNamespace(
guard_file=_fake_file_twin(guard_fn),
item_guard_files=[_fake_file_twin(item_fn)],
)
},
)
# Template keyed by POSIX paths, exactly as sync_generated_guard_code_inputs writes them.
attrs = {
"result.json": {"value": "{}"},
"proj/proj_types.py": {"value": "types-content"},
"proj/proj_api.py": {"value": "api-content"},
"proj/proj_api_impl.py": {"value": "impl-content"},
"proj/fetch_content/guard.py": {"value": "guard-content"},
"proj/fetch_content/guard_allowed_url_domains.py": {"value": "item-content"},
}
fake_tg = _make_fake_tg(RESULTS_FILENAME="result.json")
fake_tg["ToolGuardsCodeGenerationResult"].model_validate_json.return_value = fake_result
mock_component.get_vertex = MagicMock(return_value=SimpleNamespace(data={"node": {"template": attrs}}))
with patch.object(PoliciesComponent, "_import_toolguard", return_value=fake_tg):
result = mock_component.make_toolguard_result()
assert result is fake_result
assert result.domain.app_types.content == "types-content"
assert result.domain.app_api.content == "api-content"
assert result.domain.app_api_impl.content == "impl-content"
assert result.tools["fetch_content"].guard_file.content == "guard-content"
assert result.tools["fetch_content"].item_guard_files[0].content == "item-content"
def test_make_toolguard_result_missing_field_raises_clear_error(mock_component):
"""A missing generated field yields a clear ValueError, not a NoneType subscript.
Before #13727's fix a missing key surfaced as
``'NoneType' object is not subscriptable``; it must now point the user at
Generate mode instead.
"""
from types import SimpleNamespace
fake_result = SimpleNamespace(
domain=SimpleNamespace(
app_types=_fake_file_twin("proj/proj_types.py"),
app_api=_fake_file_twin("proj/proj_api.py"),
app_api_impl=_fake_file_twin("proj/proj_api_impl.py"),
),
tools={},
)
# app_types key is intentionally absent from the template.
attrs = {
"result.json": {"value": "{}"},
"proj/proj_api.py": {"value": "api-content"},
"proj/proj_api_impl.py": {"value": "impl-content"},
}
fake_tg = _make_fake_tg(RESULTS_FILENAME="result.json")
fake_tg["ToolGuardsCodeGenerationResult"].model_validate_json.return_value = fake_result
mock_component.get_vertex = MagicMock(return_value=SimpleNamespace(data={"node": {"template": attrs}}))
with (
patch.object(PoliciesComponent, "_import_toolguard", return_value=fake_tg),
pytest.raises(ValueError, match="missing from the component"),
):
mock_component.make_toolguard_result()
def test_validate_before_generate_allows_empty_api_key(mock_component):
"""api_key is optional: validation passes when only the model is set.
The field is declared required=False/advanced=True and credentials often come
from the model connection or environment, so requiring api_key here wrongly
blocked valid setups (the "model or api_key cannot be empty!" wart in #13727).
"""
mock_component.api_key = ""
mock_component.model = [{"name": "gpt-5.1", "provider": "OpenAI"}]
# Should not raise.
mock_component.validate_before_generate()
def test_validate_before_generate_still_requires_model(mock_component):
"""A missing model is still rejected after relaxing the api_key requirement."""
mock_component.api_key = ""
mock_component.model = None
with pytest.raises(ValueError, match="model cannot be empty"):
mock_component.validate_before_generate()
# Made with Bob

File diff suppressed because one or more lines are too long

View File

@ -265,8 +265,14 @@ Powered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )"""
msg = "Policies: in_tools cannot be empty!"
raise ValueError(msg)
if not self.model or not self.api_key:
msg = "Policies: model or api_key cannot be empty!"
# Only the model selection is mandatory. ``api_key`` is declared optional
# (required=False, advanced=True) and is frequently supplied by the model
# connection, an environment variable, or a global variable rather than by
# this field — requiring it here wrongly blocked valid setups with
# "model or api_key cannot be empty!". When credentials really are missing,
# ``build_model`` -> ``get_llm`` raises a clear provider-specific error.
if not self.model:
msg = "Policies: model cannot be empty!"
raise ValueError(msg)
# uncomment if willing to enforce certain models for buildtime
@ -316,23 +322,64 @@ Powered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )"""
self._verify_cached_guards(code_dir)
@staticmethod
def _template_field_key(file_name: str | Path) -> str:
r"""Normalize a generated file name to its node-template field key.
``sync_generated_guard_code_inputs`` keys every generated CodeInput by the
file's POSIX relative path (``Path.relative_to(...).as_posix()``), so the
keys always use forward slashes. The toolguard result model stores
``file_name`` as a :class:`pathlib.Path`, whose ``str()`` uses the OS
separator — backslashes on Windows. Reading back with ``str(file_name)``
therefore misses every key on Windows, ``attrs.get(...)`` returns ``None``
and the subsequent ``["value"]`` raised the cryptic
``'NoneType' object is not subscriptable`` (issue #13727).
Normalizing through ``as_posix()`` is the exact inverse of how the keys are
written, so lookups match on every platform. ``replace("\\", "/")`` is a
belt-and-suspenders guard for the rare case where ``file_name`` is already a
string carrying Windows separators (e.g. a flow generated on Windows and
opened on POSIX, where ``PurePosixPath`` would not split on backslashes).
"""
return Path(str(file_name).replace("\\", "/")).as_posix()
def make_toolguard_result(self) -> ToolGuardsCodeGenerationResult:
tg = self._import_toolguard()
attrs = self.get_vertex().data["node"]["template"]
if not attrs:
raise ValueError
msg = "Policies: component template data is missing. This may indicate a corrupted flow state."
raise ValueError(msg)
result_str = attrs[str(tg["RESULTS_FILENAME"])]["value"]
def read_content(file_name: str | Path) -> str:
"""Fetch a generated file's stored source from the node template.
Raises a clear, actionable error when the field is absent instead of
letting a missing key surface as ``'NoneType' object is not
subscriptable``. A missing field means the guard code was never
generated (or only the ``pass # FIXME`` scaffold was produced), so the
fix is to re-run Generate.
"""
key = self._template_field_key(file_name)
field = attrs.get(key)
if field is None:
msg = (
f"Policies: generated guard file '{key}' is missing from the component. "
f"Re-run in 'Generate' mode to (re)build the guard code before guarding."
)
raise ValueError(msg)
return field["value"]
result_str = read_content(tg["RESULTS_FILENAME"])
result = tg["ToolGuardsCodeGenerationResult"].model_validate_json(result_str)
result.domain.app_types.content = attrs.get(str(result.domain.app_types.file_name))["value"]
result.domain.app_api.content = attrs.get(str(result.domain.app_api.file_name))["value"]
result.domain.app_api_impl.content = attrs.get(str(result.domain.app_api_impl.file_name))["value"]
result.domain.app_types.content = read_content(result.domain.app_types.file_name)
result.domain.app_api.content = read_content(result.domain.app_api.file_name)
result.domain.app_api_impl.content = read_content(result.domain.app_api_impl.file_name)
for tool in result.tools.values():
tool.guard_file.content = attrs.get(str(tool.guard_file.file_name))["value"]
tool.guard_file.content = read_content(tool.guard_file.file_name)
for tool_item in tool.item_guard_files:
tool_item.content = attrs.get(str(tool_item.file_name))["value"]
tool_item.content = read_content(tool_item.file_name)
return result