@kontourai/flow-agents 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/publish-npm.yml +1 -1
  3. package/.github/workflows/release-please.yml +31 -0
  4. package/.github/workflows/runtime-compat.yml +118 -0
  5. package/CHANGELOG.md +38 -0
  6. package/CONTRIBUTING.md +4 -0
  7. package/README.md +58 -19
  8. package/build/src/cli/init.js +215 -5
  9. package/build/src/cli/utterance-check.js +236 -0
  10. package/build/src/cli.js +3 -0
  11. package/build/src/tools/build-universal-bundles.js +268 -0
  12. package/build/src/tools/filter-installed-packs.js +3 -0
  13. package/build/src/tools/validate-source-tree.js +6 -1
  14. package/context/scripts/telemetry/lib/config.sh +5 -1
  15. package/context/settings/flow-agents-settings.json +7 -0
  16. package/docs/agent-system-guidebook.md +4 -5
  17. package/docs/context-map.md +1 -0
  18. package/docs/index.md +46 -6
  19. package/docs/integrations/conformance.md +246 -0
  20. package/docs/integrations/framework-adapter.md +275 -0
  21. package/docs/integrations/harness-install.md +213 -0
  22. package/docs/integrations/index.md +54 -0
  23. package/docs/north-star.md +3 -3
  24. package/docs/repository-structure.md +1 -1
  25. package/docs/skills-map.md +10 -4
  26. package/docs/spec/runtime-hook-surface.md +472 -0
  27. package/docs/survey-utterance-check.md +308 -0
  28. package/docs/vision.md +45 -0
  29. package/docs/workflow-usage-guide.md +1 -1
  30. package/evals/acceptance/run.sh +4 -2
  31. package/evals/acceptance/test_opencode_harness.sh +121 -0
  32. package/evals/acceptance/test_pi_harness.sh +98 -0
  33. package/evals/integration/test_bundle_install.sh +226 -1
  34. package/evals/integration/test_bundle_lifecycle.sh +641 -0
  35. package/evals/integration/test_utterance_check.sh +518 -0
  36. package/evals/run.sh +2 -0
  37. package/evals/static/test_universal_bundles.sh +137 -2
  38. package/integrations/strands/README.md +256 -0
  39. package/integrations/strands/example.py +74 -0
  40. package/integrations/strands/flow_agents_strands/__init__.py +27 -0
  41. package/integrations/strands/flow_agents_strands/hooks.py +194 -0
  42. package/integrations/strands/flow_agents_strands/policy.py +348 -0
  43. package/integrations/strands/flow_agents_strands/steering.py +172 -0
  44. package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
  45. package/integrations/strands/pyproject.toml +38 -0
  46. package/integrations/strands/tests/__init__.py +0 -0
  47. package/integrations/strands/tests/test_hooks.py +304 -0
  48. package/integrations/strands/tests/test_policy.py +315 -0
  49. package/integrations/strands/tests/test_telemetry.py +184 -0
  50. package/integrations/strands-ts/README.md +224 -0
  51. package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
  52. package/integrations/strands-ts/package.json +53 -0
  53. package/integrations/strands-ts/src/hooks.ts +208 -0
  54. package/integrations/strands-ts/src/index.ts +22 -0
  55. package/integrations/strands-ts/src/policy.ts +345 -0
  56. package/integrations/strands-ts/src/telemetry.ts +251 -0
  57. package/integrations/strands-ts/test/test-policy.ts +322 -0
  58. package/integrations/strands-ts/test/test-telemetry.ts +226 -0
  59. package/integrations/strands-ts/tsconfig.json +20 -0
  60. package/package.json +7 -2
  61. package/packaging/conformance/README.md +142 -0
  62. package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
  63. package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
  64. package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
  65. package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
  66. package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
  67. package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
  68. package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
  69. package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
  70. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
  71. package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
  72. package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
  73. package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
  74. package/packaging/conformance/package.json +4 -0
  75. package/packaging/conformance/run-conformance.js +322 -0
  76. package/packaging/manifest.json +59 -0
  77. package/schemas/flow-agents-settings.schema.json +48 -0
  78. package/scripts/README.md +5 -0
  79. package/scripts/dogfood.js +16 -0
  80. package/scripts/hooks/opencode-hook-adapter.js +123 -0
  81. package/scripts/hooks/opencode-telemetry-hook.js +101 -0
  82. package/scripts/hooks/pi-hook-adapter.js +123 -0
  83. package/scripts/hooks/pi-telemetry-hook.js +105 -0
  84. package/scripts/hooks/run-hook.js +8 -0
  85. package/scripts/hooks/utterance-check.js +327 -0
  86. package/scripts/telemetry/lib/config.sh +5 -1
  87. package/skills/idea-to-backlog/SKILL.md +1 -1
  88. package/src/cli/init.ts +219 -6
  89. package/src/cli/utterance-check.ts +324 -0
  90. package/src/cli.ts +3 -0
  91. package/src/tools/build-universal-bundles.ts +266 -0
  92. package/src/tools/filter-installed-packs.ts +3 -0
  93. package/src/tools/validate-source-tree.ts +6 -1
  94. package/build/src/cli/docs-preview.js +0 -39
  95. package/build/src/cli/export-bookmarks.js +0 -38
  96. package/build/src/cli/import-bookmarks.js +0 -50
  97. package/build/src/cli/instinct-cli.js +0 -93
@@ -0,0 +1,194 @@
1
+ """
2
+ hooks.py — FlowAgentsHooks: the main HookProvider for AWS Strands Agents.
3
+
4
+ Design: duck-typed so strands-agents is NOT required at import time.
5
+ The class uses TYPE_CHECKING guards and string-based isinstance() avoidance so
6
+ the full module tree is importable and unit-testable without the SDK installed.
7
+
8
+ When strands-agents IS installed, FlowAgentsHooks is a valid HookProvider
9
+ because it implements the register_hooks(registry, **kwargs) protocol method.
10
+
11
+ Usage (with strands installed):
12
+
13
+ from strands import Agent
14
+ from flow_agents_strands import FlowAgentsHooks
15
+
16
+ hooks = FlowAgentsHooks(workspace=".")
17
+ system_prompt = "You are a helpful agent." + hooks.steering_context()
18
+ agent = Agent(system_prompt=system_prompt, hooks=[hooks])
19
+
20
+ Usage (without strands, e.g. in tests):
21
+
22
+ from flow_agents_strands import FlowAgentsHooks
23
+ hooks = FlowAgentsHooks()
24
+ ctx = hooks.steering_context() # works without strands
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import time
30
+ from typing import Any, Callable, Dict, Optional, TYPE_CHECKING
31
+
32
+ from .telemetry import TelemetrySink, STRANDS_TO_CANONICAL
33
+ from .policy import PolicyGate
34
+ from .steering import SteeringContext
35
+
36
+ if TYPE_CHECKING:
37
+ # These imports only run during static type-checking (mypy/pyright).
38
+ # At runtime the try/except below handles the optional SDK.
39
+ from strands.hooks import ( # type: ignore[import]
40
+ HookRegistry,
41
+ BeforeInvocationEvent,
42
+ AfterInvocationEvent,
43
+ BeforeToolCallEvent,
44
+ AfterToolCallEvent,
45
+ )
46
+
47
+
48
+ class FlowAgentsHooks:
49
+ """
50
+ Flow Agents HookProvider for AWS Strands Agents.
51
+
52
+ Implements the strands HookProvider protocol (register_hooks) via duck
53
+ typing. When strands-agents is not installed the class still fully
54
+ constructs and is usable for telemetry emission and steering context
55
+ loading.
56
+
57
+ Args:
58
+ sink_path: Directory or file path for JSONL telemetry output.
59
+ Default: <workspace>/.flow-agents/.telemetry/full.jsonl
60
+ workspace: Root of the workspace to discover .flow-agents/ from.
61
+ Default: current working directory.
62
+ agent_name: Agent identifier embedded in telemetry events.
63
+ runtime: Runtime label embedded in telemetry events.
64
+ policy_gate: Optional PolicyGate instance; defaults to PolicyGate().
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ sink_path: Optional[str] = None,
70
+ workspace: Optional[str] = None,
71
+ agent_name: str = "strands-agent",
72
+ runtime: str = "strands",
73
+ policy_gate: Optional[PolicyGate] = None,
74
+ ) -> None:
75
+ self._sink = TelemetrySink(
76
+ sink_path=sink_path,
77
+ workspace=workspace,
78
+ agent_name=agent_name,
79
+ runtime=runtime,
80
+ )
81
+ self._policy = policy_gate if policy_gate is not None else PolicyGate()
82
+ self._steering = SteeringContext(workspace=workspace)
83
+ self._session_start_ts: Optional[float] = None
84
+
85
+ # ------------------------------------------------------------------
86
+ # Public API available WITHOUT strands installed
87
+ # ------------------------------------------------------------------
88
+
89
+ def steering_context(self) -> str:
90
+ """
91
+ Return workflow-steering context text for the current workspace.
92
+
93
+ Callers should append this to the Agent's system prompt at construction
94
+ time — e.g.:
95
+
96
+ system_prompt = base_prompt + hooks.steering_context()
97
+
98
+ This is the documented spike approach because Strands'
99
+ BeforeInvocationEvent does not expose a mutable system_prompt.
100
+ See README.md § Limitations.
101
+ """
102
+ text = self._steering.load()
103
+ if text:
104
+ self._sink.emit_steering(text)
105
+ return text
106
+
107
+ # ------------------------------------------------------------------
108
+ # HookProvider protocol (register_hooks)
109
+ # ------------------------------------------------------------------
110
+
111
+ def register_hooks(self, registry: Any, **kwargs: Any) -> None:
112
+ """
113
+ Register Flow Agents callbacks with a Strands HookRegistry.
114
+
115
+ This method is the sole method required by the HookProvider protocol.
116
+ The registry parameter is typed as Any so the module compiles without
117
+ strands-agents installed; at runtime the real HookRegistry is passed.
118
+ """
119
+ # Import lazily — only reachable when strands IS installed.
120
+ try:
121
+ from strands.hooks import ( # type: ignore[import]
122
+ BeforeInvocationEvent,
123
+ AfterInvocationEvent,
124
+ BeforeToolCallEvent,
125
+ AfterToolCallEvent,
126
+ AgentInitializedEvent,
127
+ )
128
+ except ImportError as exc:
129
+ raise ImportError(
130
+ "strands-agents is required to register hooks. "
131
+ "Install it with: pip install flow-agents-strands[strands]"
132
+ ) from exc
133
+
134
+ registry.add_callback(AgentInitializedEvent, self._on_agent_initialized)
135
+ registry.add_callback(BeforeInvocationEvent, self._on_before_invocation)
136
+ registry.add_callback(AfterInvocationEvent, self._on_after_invocation)
137
+ registry.add_callback(BeforeToolCallEvent, self._on_before_tool_call)
138
+ registry.add_callback(AfterToolCallEvent, self._on_after_tool_call)
139
+
140
+ # ------------------------------------------------------------------
141
+ # Private callbacks
142
+ # ------------------------------------------------------------------
143
+
144
+ def _on_agent_initialized(self, event: Any) -> None:
145
+ """AgentInitializedEvent → agentSpawn / session.start"""
146
+ self._session_start_ts = time.monotonic()
147
+ self._sink.emit_session_start()
148
+
149
+ def _on_before_invocation(self, event: Any) -> None:
150
+ """BeforeInvocationEvent → userPromptSubmit / turn.user"""
151
+ if self._session_start_ts is None:
152
+ self._session_start_ts = time.monotonic()
153
+ self._sink.emit("userPromptSubmit")
154
+
155
+ def _on_after_invocation(self, event: Any) -> None:
156
+ """AfterInvocationEvent → stop / session.end"""
157
+ duration_s = 0.0
158
+ if self._session_start_ts is not None:
159
+ duration_s = time.monotonic() - self._session_start_ts
160
+ self._sink.emit_session_end(duration_s=duration_s)
161
+
162
+ def _on_before_tool_call(self, event: Any) -> None:
163
+ """
164
+ BeforeToolCallEvent → preToolUse / tool.invoke + policy gate.
165
+
166
+ If the policy gate blocks the call, sets event.cancel_tool to the
167
+ block reason (Strands will cancel the tool and return the message
168
+ as the tool result).
169
+ """
170
+ tool_use = getattr(event, "tool_use", {}) or {}
171
+ tool_name = tool_use.get("name", "")
172
+ tool_input = tool_use.get("input", {}) or {}
173
+
174
+ # Emit telemetry first (fail-open: policy check follows)
175
+ self._sink.emit_tool_invoke(tool_name=tool_name, tool_input=tool_input)
176
+
177
+ # Policy gate
178
+ block_reason = self._policy.check_tool_call(
179
+ tool_name=tool_name,
180
+ tool_input=tool_input,
181
+ )
182
+ if block_reason:
183
+ try:
184
+ event.cancel_tool = block_reason
185
+ except AttributeError:
186
+ # Some event mock or future SDK change; log and continue
187
+ pass
188
+
189
+ def _on_after_tool_call(self, event: Any) -> None:
190
+ """AfterToolCallEvent → postToolUse / tool.result"""
191
+ tool_use = getattr(event, "tool_use", {}) or {}
192
+ tool_name = tool_use.get("name", "")
193
+ result = getattr(event, "result", None)
194
+ self._sink.emit_tool_result(tool_name=tool_name, tool_output=result)
@@ -0,0 +1,348 @@
1
+ """
2
+ policy.py — Policy gates for BeforeToolCallEvent.
3
+
4
+ Primary binding: subprocess to the canonical Node.js engine
5
+ (scripts/hooks/run-hook.js → config-protection.js) for the authoritative
6
+ policy decision. The tool-name pre-filter (write-like tools only) is applied
7
+ in Python before calling the engine, since the engine itself does not filter by
8
+ tool name — it blocks on the file basename alone.
9
+
10
+ Fallback: if Node.js is absent or the engine script cannot be located, the gate
11
+ degrades to the pure-Python implementation of the same logic and emits a
12
+ one-time RuntimeWarning. See README.md §Limitations.
13
+
14
+ Engine contract (contract_version "1.0"):
15
+ - Payload: JSON on stdin with hook_event_name, tool_name, tool_input fields.
16
+ - Exit code 0 = allow, exit code 2 = block, other = error (fail-open).
17
+ - Stderr carries the block reason when exit code is 2.
18
+ - See docs/spec/runtime-hook-surface.md §8 for the full contract.
19
+
20
+ Custom protected_files: when the caller passes a non-default ``protected_files``
21
+ frozenset, the engine subprocess is bypassed and the Python evaluation is used
22
+ directly (the subprocess cannot receive a runtime-custom set).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import shutil
30
+ import subprocess
31
+ import warnings
32
+ from pathlib import Path
33
+ from typing import Optional
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Locate the canonical engine — supports both installed-package and repo layouts.
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def _find_engine_paths() -> tuple[Optional[str], Optional[str]]:
40
+ """
41
+ Return (node_executable, run_hook_path) or (None, None) if unavailable.
42
+
43
+ Search order for run-hook.js:
44
+ 1. Env var FLOW_AGENTS_ENGINE_PATH (explicit override).
45
+ 2. Relative to this file: ../../../../scripts/hooks/run-hook.js
46
+ (works when running from the source repo checkout).
47
+ 3. Installed via npm: walk up from CWD looking for
48
+ node_modules/@kontourai/flow-agents/scripts/hooks/run-hook.js
49
+ """
50
+ node = shutil.which("node")
51
+ if not node:
52
+ return None, None
53
+
54
+ # 1. Explicit override
55
+ env_path = os.environ.get("FLOW_AGENTS_ENGINE_PATH")
56
+ if env_path:
57
+ p = Path(env_path)
58
+ if p.is_file():
59
+ return node, str(p)
60
+
61
+ # 2. Relative to this source file (repo checkout: integrations/strands/flow_agents_strands/)
62
+ candidate = Path(__file__).parent.parent.parent.parent / "scripts" / "hooks" / "run-hook.js"
63
+ if candidate.is_file():
64
+ return node, str(candidate)
65
+
66
+ # 3. npm-installed package layout
67
+ cwd = Path.cwd()
68
+ for parent in [cwd, *cwd.parents]:
69
+ candidate = (
70
+ parent / "node_modules" / "@kontourai" / "flow-agents" / "scripts" / "hooks" / "run-hook.js"
71
+ )
72
+ if candidate.is_file():
73
+ return node, str(candidate)
74
+
75
+ return node, None
76
+
77
+
78
+ # Resolved at module import time; callers can override via constructor parameters.
79
+ _NODE_BIN, _RUN_HOOK_PATH = _find_engine_paths()
80
+
81
+
82
+ # Sentinel for "not provided" constructor parameters
83
+ _UNSET = object()
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Pure-Python fallback — mirrors config-protection.js PROTECTED_FILES
87
+ # ---------------------------------------------------------------------------
88
+
89
+ PROTECTED_FILES = frozenset(
90
+ [
91
+ # ESLint
92
+ ".eslintrc",
93
+ ".eslintrc.js",
94
+ ".eslintrc.cjs",
95
+ ".eslintrc.json",
96
+ ".eslintrc.yml",
97
+ ".eslintrc.yaml",
98
+ "eslint.config.js",
99
+ "eslint.config.mjs",
100
+ "eslint.config.cjs",
101
+ "eslint.config.ts",
102
+ "eslint.config.mts",
103
+ "eslint.config.cts",
104
+ # Prettier
105
+ ".prettierrc",
106
+ ".prettierrc.js",
107
+ ".prettierrc.cjs",
108
+ ".prettierrc.json",
109
+ ".prettierrc.yml",
110
+ ".prettierrc.yaml",
111
+ "prettier.config.js",
112
+ "prettier.config.cjs",
113
+ "prettier.config.mjs",
114
+ # Biome
115
+ "biome.json",
116
+ "biome.jsonc",
117
+ # Ruff
118
+ ".ruff.toml",
119
+ "ruff.toml",
120
+ # Others
121
+ ".shellcheckrc",
122
+ ".stylelintrc",
123
+ ".stylelintrc.json",
124
+ ".stylelintrc.yml",
125
+ ".markdownlint.json",
126
+ ".markdownlint.yaml",
127
+ ".markdownlintrc",
128
+ ]
129
+ )
130
+
131
+ _BLOCK_REASON_TEMPLATE = (
132
+ "BLOCKED: Modifying {basename} is not allowed. "
133
+ "Fix the source code to satisfy linter/formatter rules instead of "
134
+ "weakening the config. If this is a legitimate config change, "
135
+ "disable the config-protection policy gate temporarily."
136
+ )
137
+
138
+ # Write-like tool names — both the engine and the Python fallback only gate
139
+ # write-like tools. Reads on protected files are always allowed.
140
+ _WRITE_TOOLS = frozenset(
141
+ {
142
+ "edit",
143
+ "write",
144
+ "fs_write",
145
+ "apply_patch",
146
+ "create_file",
147
+ "str_replace_editor",
148
+ }
149
+ )
150
+
151
+
152
+ def _python_config_protection(
153
+ tool_name: str,
154
+ tool_input: dict,
155
+ protected: frozenset = PROTECTED_FILES,
156
+ ) -> Optional[str]:
157
+ """
158
+ Pure-Python evaluation of config-protection.
159
+
160
+ Mirrors the logic in scripts/hooks/config-protection.js.
161
+ Called when Node.js is unavailable or a custom protected set is in use.
162
+ """
163
+ if tool_name.lower() not in _WRITE_TOOLS:
164
+ return None
165
+ file_path = tool_input.get("path") or tool_input.get("file_path") or ""
166
+ if not file_path:
167
+ return None
168
+ basename = Path(file_path).name
169
+ if basename in protected:
170
+ return _BLOCK_REASON_TEMPLATE.format(basename=basename)
171
+ return None
172
+
173
+
174
+ # ---------------------------------------------------------------------------
175
+ # Subprocess binding to the engine contract
176
+ # ---------------------------------------------------------------------------
177
+
178
+ def _invoke_engine(
179
+ hook_id: str,
180
+ hook_script: str,
181
+ payload: dict,
182
+ *,
183
+ node_bin: str,
184
+ run_hook_path: str,
185
+ extra_env: Optional[dict] = None,
186
+ ) -> tuple[int, str, str]:
187
+ """
188
+ Invoke the canonical engine via subprocess.
189
+
190
+ Returns (exit_code, stdout, stderr).
191
+ On subprocess errors returns (1, "", "<error>") so callers can fail-open.
192
+ """
193
+ env = dict(os.environ)
194
+ if extra_env:
195
+ env.update(extra_env)
196
+ env["FLOW_AGENTS_HOOK_RUNTIME"] = "strands"
197
+
198
+ # run-hook.js resolves hook_script relative to its own directory
199
+ hooks_dir = str(Path(run_hook_path).parent)
200
+
201
+ try:
202
+ result = subprocess.run(
203
+ [node_bin, run_hook_path, hook_id, hook_script],
204
+ input=json.dumps(payload),
205
+ capture_output=True,
206
+ text=True,
207
+ timeout=15,
208
+ env=env,
209
+ cwd=hooks_dir,
210
+ )
211
+ return result.returncode, result.stdout, result.stderr
212
+ except subprocess.TimeoutExpired:
213
+ return 1, "", "[policy] engine subprocess timed out"
214
+ except OSError as exc:
215
+ return 1, "", f"[policy] engine subprocess failed: {exc}"
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # PolicyGate
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ class PolicyGate:
224
+ """
225
+ Evaluates tool-call policy gates.
226
+
227
+ Designed for use inside BeforeToolCallEvent callbacks; does NOT depend on
228
+ any Strands import so it is fully testable without the SDK installed.
229
+
230
+ Primary mode: spawns ``node run-hook.js config-protection.js`` to delegate
231
+ to the engine contract (contract_version "1.0"). The tool-name pre-filter
232
+ is applied in Python before calling the engine.
233
+
234
+ Fallback mode: if Node.js or the engine script is unavailable, degrades to
235
+ the built-in Python implementation and emits a one-time RuntimeWarning.
236
+
237
+ Custom protected_files: if a non-default frozenset is passed, Python
238
+ evaluation is used directly (the engine subprocess cannot accept a
239
+ runtime-custom set). This is intended for tests and local override only.
240
+
241
+ The ``_node_bin`` and ``_run_hook_path`` constructor parameters allow tests
242
+ to inject fakes without touching the filesystem.
243
+ """
244
+
245
+ def __init__(
246
+ self,
247
+ protected_files: Optional[frozenset] = None,
248
+ _node_bin: object = _UNSET,
249
+ _run_hook_path: object = _UNSET,
250
+ ) -> None:
251
+ # Allow tests to inject explicit values (including None to force fallback).
252
+ # _UNSET means "use the module-level resolved value".
253
+ self._node_bin: Optional[str] = (
254
+ _node_bin if _node_bin is not _UNSET else _NODE_BIN # type: ignore[assignment]
255
+ )
256
+ self._run_hook_path: Optional[str] = (
257
+ _run_hook_path if _run_hook_path is not _UNSET else _RUN_HOOK_PATH # type: ignore[assignment]
258
+ )
259
+ # Custom protected set: bypass engine subprocess, use Python directly.
260
+ self._custom_protected: Optional[frozenset] = protected_files
261
+ self._warned_fallback = False
262
+
263
+ @property
264
+ def _engine_available(self) -> bool:
265
+ return bool(self._node_bin and self._run_hook_path)
266
+
267
+ def _warn_fallback(self) -> None:
268
+ if not self._warned_fallback:
269
+ self._warned_fallback = True
270
+ warnings.warn(
271
+ "flow-agents-strands: Node.js or the Flow Agents engine script is "
272
+ "not available. Policy gates are degrading to the built-in Python "
273
+ "fallback (fail-open for unknown cases). Install Node.js and ensure "
274
+ "the @kontourai/flow-agents package is reachable to use the canonical "
275
+ "engine contract. See README.md §Limitations.",
276
+ RuntimeWarning,
277
+ stacklevel=4,
278
+ )
279
+
280
+ def check_tool_call(
281
+ self,
282
+ tool_name: str,
283
+ tool_input: dict,
284
+ ) -> Optional[str]:
285
+ """
286
+ Evaluate policy for a tool call.
287
+
288
+ Returns None if the call is allowed, or a block-reason string if it
289
+ should be cancelled (the string becomes the cancel_tool message).
290
+ """
291
+ return self._check_config_protection(tool_name, tool_input)
292
+
293
+ def _check_config_protection(
294
+ self,
295
+ tool_name: str,
296
+ tool_input: dict,
297
+ ) -> Optional[str]:
298
+ """
299
+ Invoke config-protection.
300
+
301
+ Tool-name pre-filter: only write-like tools are gated. Reads on
302
+ protected files are always allowed (this is the same rule as the JS
303
+ engine — the engine short-circuits for the same reason; the pre-filter
304
+ avoids the subprocess round-trip for non-write tools).
305
+
306
+ If a custom protected_files set was passed to the constructor, Python
307
+ evaluation is used instead of the engine subprocess.
308
+
309
+ If Node.js or the engine script is unavailable, falls back to Python
310
+ evaluation with the default PROTECTED_FILES set.
311
+ """
312
+ # Tool-name pre-filter (read tools always allowed)
313
+ if tool_name.lower() not in _WRITE_TOOLS:
314
+ return None
315
+
316
+ # Custom protected set → Python evaluation only
317
+ if self._custom_protected is not None:
318
+ return _python_config_protection(tool_name, tool_input, self._custom_protected)
319
+
320
+ # Engine subprocess → authoritative decision
321
+ if self._engine_available:
322
+ payload = {
323
+ "hook_event_name": "PreToolUse",
324
+ "tool_name": tool_name,
325
+ "tool_input": tool_input,
326
+ }
327
+
328
+ exit_code, stdout, stderr = _invoke_engine(
329
+ hook_id="config-protection",
330
+ hook_script="config-protection.js",
331
+ payload=payload,
332
+ node_bin=self._node_bin,
333
+ run_hook_path=self._run_hook_path,
334
+ )
335
+
336
+ if exit_code == 2:
337
+ reason = stderr.strip() or stdout.strip() or "BLOCKED: config-protection policy blocked this action."
338
+ return reason
339
+
340
+ if exit_code == 0:
341
+ return None
342
+
343
+ # Engine error (exit_code not 0 or 2) → fail-open
344
+ return None
345
+
346
+ # No engine available → Python fallback
347
+ self._warn_fallback()
348
+ return _python_config_protection(tool_name, tool_input)