@kontourai/flow-agents 0.1.2 → 0.3.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.
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/release-please.yml +31 -0
- package/.github/workflows/runtime-compat.yml +118 -0
- package/CHANGELOG.md +46 -0
- package/CONTRIBUTING.md +4 -0
- package/README.md +80 -18
- package/build/src/cli/flow-kit.js +9 -4
- package/build/src/cli/init.js +215 -5
- package/build/src/cli/runtime-adapter.js +9 -5
- package/build/src/cli/telemetry-doctor.js +4 -1
- package/build/src/cli/utterance-check.js +65 -1
- package/build/src/runtime-adapters.js +34 -0
- package/build/src/tools/build-universal-bundles.js +285 -0
- package/build/src/tools/filter-installed-packs.js +3 -0
- package/build/src/tools/validate-source-tree.js +5 -1
- package/console.telemetry.json +115 -20
- package/context/scripts/telemetry/lib/config.sh +5 -1
- package/context/settings/flow-agents-settings.json +7 -0
- package/docs/_layouts/default.html +2 -0
- package/docs/context-map.md +1 -0
- package/docs/index.md +53 -4
- package/docs/integrations/conformance.md +246 -0
- package/docs/integrations/framework-adapter.md +275 -0
- package/docs/integrations/harness-install.md +213 -0
- package/docs/integrations/index.md +58 -0
- package/docs/integrations/knowledge-kit-live.md +211 -0
- package/docs/kit-authoring-guide.md +169 -0
- package/docs/north-star.md +2 -2
- package/docs/spec/runtime-hook-surface.md +525 -0
- package/docs/survey-utterance-check.md +211 -94
- package/docs/vision.md +45 -0
- package/evals/acceptance/run.sh +13 -2
- package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
- package/evals/acceptance/test_opencode_harness.sh +121 -0
- package/evals/acceptance/test_pi_harness.sh +113 -0
- package/evals/integration/test_bundle_install.sh +226 -1
- package/evals/integration/test_bundle_lifecycle.sh +641 -0
- package/evals/integration/test_runtime_adapter_activation.sh +113 -1
- package/evals/integration/test_utterance_check.sh +291 -44
- package/evals/run.sh +2 -0
- package/evals/static/test_universal_bundles.sh +137 -2
- package/integrations/strands/README.md +256 -0
- package/integrations/strands/example.py +74 -0
- package/integrations/strands/examples/knowledge_kit_live.py +461 -0
- package/integrations/strands/flow_agents_strands/__init__.py +27 -0
- package/integrations/strands/flow_agents_strands/hooks.py +194 -0
- package/integrations/strands/flow_agents_strands/policy.py +348 -0
- package/integrations/strands/flow_agents_strands/steering.py +225 -0
- package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
- package/integrations/strands/pyproject.toml +38 -0
- package/integrations/strands/tests/__init__.py +0 -0
- package/integrations/strands/tests/test_hooks.py +392 -0
- package/integrations/strands/tests/test_policy.py +315 -0
- package/integrations/strands/tests/test_telemetry.py +184 -0
- package/integrations/strands-ts/README.md +224 -0
- package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
- package/integrations/strands-ts/package.json +53 -0
- package/integrations/strands-ts/src/hooks.ts +312 -0
- package/integrations/strands-ts/src/index.ts +22 -0
- package/integrations/strands-ts/src/policy.ts +345 -0
- package/integrations/strands-ts/src/telemetry.ts +251 -0
- package/integrations/strands-ts/test/test-policy.ts +322 -0
- package/integrations/strands-ts/test/test-steering.ts +159 -0
- package/integrations/strands-ts/test/test-telemetry.ts +226 -0
- package/integrations/strands-ts/tsconfig.json +20 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +821 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
- package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
- package/kits/knowledge/docs/README.md +135 -0
- package/kits/knowledge/docs/store-contract.md +526 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
- package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
- package/kits/knowledge/flows/compile.flow.json +60 -0
- package/kits/knowledge/flows/consolidate.flow.json +77 -0
- package/kits/knowledge/flows/ingest.flow.json +60 -0
- package/kits/knowledge/flows/store-contract.flow.json +48 -0
- package/kits/knowledge/flows/synthesize.flow.json +77 -0
- package/kits/knowledge/kit.json +78 -0
- package/package.json +7 -2
- package/packaging/conformance/README.md +142 -0
- package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
- package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
- package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
- package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
- package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
- package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
- package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
- package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
- package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
- package/packaging/conformance/package.json +4 -0
- package/packaging/conformance/run-conformance.js +322 -0
- package/packaging/manifest.json +59 -0
- package/schemas/flow-agents-settings.schema.json +48 -0
- package/scripts/README.md +4 -0
- package/scripts/dogfood.js +16 -0
- package/scripts/hooks/opencode-hook-adapter.js +123 -0
- package/scripts/hooks/opencode-telemetry-hook.js +101 -0
- package/scripts/hooks/pi-hook-adapter.js +123 -0
- package/scripts/hooks/pi-telemetry-hook.js +105 -0
- package/scripts/hooks/run-hook.js +8 -0
- package/scripts/hooks/utterance-check.js +124 -22
- package/scripts/telemetry/lib/config.sh +5 -1
- package/src/cli/flow-kit.ts +10 -4
- package/src/cli/init.ts +219 -6
- package/src/cli/runtime-adapter.ts +10 -5
- package/src/cli/telemetry-doctor.ts +4 -1
- package/src/cli/utterance-check.ts +71 -1
- package/src/runtime-adapters.ts +35 -0
- package/src/tools/build-universal-bundles.ts +283 -0
- package/src/tools/filter-installed-packs.ts +3 -0
- package/src/tools/validate-source-tree.ts +5 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hooks.ts — FlowAgentsHooks: the main hook provider for Strands Agents TypeScript SDK.
|
|
3
|
+
*
|
|
4
|
+
* Design: duck-typed against the Strands TS registry/event shapes so the module
|
|
5
|
+
* compiles and tests WITHOUT strands-agents installed. strands-agents is listed
|
|
6
|
+
* as an optional peerDependency in package.json.
|
|
7
|
+
*
|
|
8
|
+
* When strands-agents IS installed, FlowAgentsHooks is a valid HookProvider
|
|
9
|
+
* because it implements registerHooks(registry) which calls:
|
|
10
|
+
* registry.addCallback(EventClass, callback)
|
|
11
|
+
*
|
|
12
|
+
* Usage (with strands-agents installed):
|
|
13
|
+
*
|
|
14
|
+
* import { Agent, BeforeInvocationEvent, AfterInvocationEvent,
|
|
15
|
+
* BeforeToolCallEvent, AfterToolCallEvent } from "@strands-agents/sdk";
|
|
16
|
+
* import { FlowAgentsHooks } from "@kontourai/flow-agents-strands";
|
|
17
|
+
*
|
|
18
|
+
* const hooks = new FlowAgentsHooks({ workspace: "." });
|
|
19
|
+
* const agent = new Agent({ hooks: [hooks] });
|
|
20
|
+
* // or: agent.addHook(BeforeInvocationEvent, cb);
|
|
21
|
+
*
|
|
22
|
+
* Usage (without strands-agents, e.g. tests):
|
|
23
|
+
*
|
|
24
|
+
* import { FlowAgentsHooks } from "@kontourai/flow-agents-strands";
|
|
25
|
+
* const hooks = new FlowAgentsHooks();
|
|
26
|
+
* // All methods callable; registerHooks() is a no-op without the SDK.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { TelemetrySink } from "./telemetry.js";
|
|
32
|
+
import { PolicyGate } from "./policy.js";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Duck-typed Strands registry/event interfaces
|
|
36
|
+
// These match the Strands TS SDK surface structurally; we do NOT import the
|
|
37
|
+
// actual SDK so the module compiles without it being installed.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Minimal duck-type for a Strands-TS event class constructor. */
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
type EventClass = new (...args: any[]) => unknown;
|
|
43
|
+
|
|
44
|
+
/** Minimal duck-type for a Strands-TS HookRegistry. */
|
|
45
|
+
export interface HookRegistry {
|
|
46
|
+
addCallback(eventClass: EventClass, callback: (event: StrandsEvent) => void): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Minimal duck-type for Strands events we handle. */
|
|
50
|
+
export interface StrandsEvent {
|
|
51
|
+
// BeforeToolCallEvent fields
|
|
52
|
+
toolName?: string;
|
|
53
|
+
toolInput?: Record<string, unknown>;
|
|
54
|
+
// TS variant: cancellable via cancel property
|
|
55
|
+
cancel?: string;
|
|
56
|
+
// AfterToolCallEvent
|
|
57
|
+
retry?: boolean;
|
|
58
|
+
result?: unknown;
|
|
59
|
+
// Common optional fields
|
|
60
|
+
[key: string]: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Kit flow discovery (Issue #32, Decision Q3: option (a))
|
|
65
|
+
//
|
|
66
|
+
// activateStrandsLocal (src/runtime-adapters.ts) writes kit flow files to
|
|
67
|
+
// .flow-agents/runtime/strands/flows/<kit-id>/<asset-id>.flow.json.
|
|
68
|
+
// steeringContext() reads those files and surfaces their id + description
|
|
69
|
+
// so the agent is aware of available workflow guidance without the hooks
|
|
70
|
+
// needing to know anything about the catalog layout.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
interface KitFlowEntry {
|
|
74
|
+
kitId: string;
|
|
75
|
+
assetId: string;
|
|
76
|
+
description: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findRepoRoot(start: string): string {
|
|
80
|
+
let current = path.resolve(start);
|
|
81
|
+
for (let i = 0; i < 40; i++) {
|
|
82
|
+
if (
|
|
83
|
+
fs.existsSync(path.join(current, ".git")) ||
|
|
84
|
+
fs.existsSync(path.join(current, "AGENTS.md"))
|
|
85
|
+
) {
|
|
86
|
+
return current;
|
|
87
|
+
}
|
|
88
|
+
const parent = path.dirname(current);
|
|
89
|
+
if (parent === current) break;
|
|
90
|
+
current = parent;
|
|
91
|
+
}
|
|
92
|
+
return path.resolve(start);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readKitFlows(flowAgentsDir: string): KitFlowEntry[] {
|
|
96
|
+
const flowsDir = path.join(flowAgentsDir, "runtime", "strands", "flows");
|
|
97
|
+
if (!fs.existsSync(flowsDir)) return [];
|
|
98
|
+
const results: KitFlowEntry[] = [];
|
|
99
|
+
|
|
100
|
+
function walkDir(dir: string): void {
|
|
101
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
102
|
+
const full = path.join(dir, name);
|
|
103
|
+
const stat = fs.statSync(full);
|
|
104
|
+
if (stat.isDirectory()) {
|
|
105
|
+
walkDir(full);
|
|
106
|
+
} else if (name.endsWith(".flow.json")) {
|
|
107
|
+
try {
|
|
108
|
+
const payload = JSON.parse(fs.readFileSync(full, "utf8")) as Record<string, unknown>;
|
|
109
|
+
const stemName = name.replace(/\.flow\.json$/, "");
|
|
110
|
+
const assetId = typeof payload.id === "string" ? payload.id : stemName;
|
|
111
|
+
const description = typeof payload.description === "string" ? payload.description : "";
|
|
112
|
+
// kit_id is the directory component between flows/ and the file
|
|
113
|
+
const rel = path.relative(flowsDir, full);
|
|
114
|
+
const relParts = rel.split(path.sep);
|
|
115
|
+
const kitId = relParts.length >= 2 ? relParts[0] : "";
|
|
116
|
+
results.push({ kitId, assetId, description });
|
|
117
|
+
} catch {
|
|
118
|
+
// Malformed file — skip silently (fail-open)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
walkDir(flowsDir);
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildKitFlowsHint(flows: KitFlowEntry[]): string {
|
|
129
|
+
if (flows.length === 0) return "";
|
|
130
|
+
const lines = ["KIT FLOWS: the following kit flows are activated for this workspace:"];
|
|
131
|
+
for (const flow of flows) {
|
|
132
|
+
const desc = flow.description ? ` — ${flow.description.slice(0, 120)}` : "";
|
|
133
|
+
lines.push(` • ${flow.assetId}${desc}`);
|
|
134
|
+
}
|
|
135
|
+
return lines.join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// FlowAgentsHooks options
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
export interface FlowAgentsHooksOptions {
|
|
143
|
+
/** JSONL telemetry sink path or directory. Default: <workspace>/.telemetry/full.jsonl */
|
|
144
|
+
sinkPath?: string;
|
|
145
|
+
/** Root of the workspace (reads .flow-agents/ and .telemetry/). Default: process.cwd() */
|
|
146
|
+
workspace?: string;
|
|
147
|
+
/** Agent identifier embedded in telemetry. Default: "strands-agent" */
|
|
148
|
+
agentName?: string;
|
|
149
|
+
/** Runtime label in telemetry. Default: "strands-ts" */
|
|
150
|
+
runtime?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Root of the @kontourai/flow-agents package for native engine import.
|
|
153
|
+
* Defaults to auto-discovery (see policy.ts).
|
|
154
|
+
*/
|
|
155
|
+
engineRoot?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// FlowAgentsHooks
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export class FlowAgentsHooks {
|
|
163
|
+
private readonly sink: TelemetrySink;
|
|
164
|
+
private readonly policyGate: PolicyGate;
|
|
165
|
+
private readonly _workspace: string;
|
|
166
|
+
private _sessionStartMs: number | null = null;
|
|
167
|
+
|
|
168
|
+
constructor(options: FlowAgentsHooksOptions = {}) {
|
|
169
|
+
this._workspace = findRepoRoot(options.workspace ?? process.cwd());
|
|
170
|
+
|
|
171
|
+
this.sink = new TelemetrySink({
|
|
172
|
+
sinkPath: options.sinkPath,
|
|
173
|
+
workspace: options.workspace,
|
|
174
|
+
agentName: options.agentName ?? "strands-agent",
|
|
175
|
+
runtime: options.runtime ?? "strands-ts",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
this.policyGate = new PolicyGate({
|
|
179
|
+
engineRoot: options.engineRoot,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --------------------------------------------------------------------------
|
|
184
|
+
// Steering context — available without strands-agents installed (Issue #32 AC2)
|
|
185
|
+
// --------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Return workflow-steering context text for the current workspace.
|
|
189
|
+
*
|
|
190
|
+
* Includes activated kit flows discovered from the strands-local runtime
|
|
191
|
+
* path (.flow-agents/runtime/strands/flows/) written by
|
|
192
|
+
* `flow-kit activate --adapter strands-local`.
|
|
193
|
+
*
|
|
194
|
+
* Callers should prepend this to the Agent's system prompt:
|
|
195
|
+
*
|
|
196
|
+
* const hooks = new FlowAgentsHooks({ workspace: "." });
|
|
197
|
+
* const agent = new Agent({ systemPrompt: basePrompt + hooks.steeringContext() });
|
|
198
|
+
*/
|
|
199
|
+
steeringContext(): string {
|
|
200
|
+
const flowAgentsDir = path.join(this._workspace, ".flow-agents");
|
|
201
|
+
const flows = readKitFlows(flowAgentsDir);
|
|
202
|
+
const kitFlowsHint = buildKitFlowsHint(flows);
|
|
203
|
+
if (!kitFlowsHint) return "";
|
|
204
|
+
return "\n\n---\n" + kitFlowsHint + "\n---";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --------------------------------------------------------------------------
|
|
208
|
+
// HookProvider protocol — registerHooks(registry)
|
|
209
|
+
// This is the sole method required by the Strands HookProvider protocol.
|
|
210
|
+
// --------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Register Flow Agents callbacks with a Strands HookRegistry.
|
|
214
|
+
*
|
|
215
|
+
* Requires strands-agents to be installed. If the SDK is absent,
|
|
216
|
+
* this method throws ImportError with instructions.
|
|
217
|
+
*/
|
|
218
|
+
registerHooks(registry: HookRegistry): void {
|
|
219
|
+
// Lazily import Strands event classes. If the SDK is not installed,
|
|
220
|
+
// a helpful error is thrown.
|
|
221
|
+
let BeforeInvocationEvent: EventClass;
|
|
222
|
+
let AfterInvocationEvent: EventClass;
|
|
223
|
+
let BeforeToolCallEvent: EventClass;
|
|
224
|
+
let AfterToolCallEvent: EventClass;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
// Dynamic import is used so the module compiles without the SDK installed.
|
|
228
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
229
|
+
const sdk = require("strands-agents") as {
|
|
230
|
+
BeforeInvocationEvent: EventClass;
|
|
231
|
+
AfterInvocationEvent: EventClass;
|
|
232
|
+
BeforeToolCallEvent: EventClass;
|
|
233
|
+
AfterToolCallEvent: EventClass;
|
|
234
|
+
};
|
|
235
|
+
BeforeInvocationEvent = sdk.BeforeInvocationEvent;
|
|
236
|
+
AfterInvocationEvent = sdk.AfterInvocationEvent;
|
|
237
|
+
BeforeToolCallEvent = sdk.BeforeToolCallEvent;
|
|
238
|
+
AfterToolCallEvent = sdk.AfterToolCallEvent;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
"strands-agents is required to register hooks. " +
|
|
242
|
+
"Install it with: npm install @strands-agents/sdk\n" +
|
|
243
|
+
`Original error: ${err instanceof Error ? err.message : String(err)}`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
registry.addCallback(BeforeInvocationEvent, (event) => this.onBeforeInvocation(event));
|
|
248
|
+
registry.addCallback(AfterInvocationEvent, (event) => this.onAfterInvocation(event));
|
|
249
|
+
registry.addCallback(BeforeToolCallEvent, (event) => this.onBeforeToolCall(event));
|
|
250
|
+
registry.addCallback(AfterToolCallEvent, (event) => this.onAfterToolCall(event));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --------------------------------------------------------------------------
|
|
254
|
+
// Callbacks — public for direct wiring in tests / without SDK
|
|
255
|
+
// --------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/** BeforeInvocationEvent → userPromptSubmit / turn.user */
|
|
258
|
+
onBeforeInvocation(_event: StrandsEvent): void {
|
|
259
|
+
if (this._sessionStartMs === null) {
|
|
260
|
+
this._sessionStartMs = Date.now();
|
|
261
|
+
}
|
|
262
|
+
this.sink.emitUserPromptSubmit();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** AfterInvocationEvent → stop / session.end */
|
|
266
|
+
onAfterInvocation(_event: StrandsEvent): void {
|
|
267
|
+
const durationMs =
|
|
268
|
+
this._sessionStartMs !== null ? Date.now() - this._sessionStartMs : 0;
|
|
269
|
+
this.sink.emitSessionEnd(durationMs);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* BeforeToolCallEvent → preToolUse / tool.invoke + config-protection policy gate.
|
|
274
|
+
*
|
|
275
|
+
* If the policy gate blocks the call, sets event.cancel to the block reason
|
|
276
|
+
* (Strands TS variant: event.cancel = "reason").
|
|
277
|
+
*/
|
|
278
|
+
onBeforeToolCall(event: StrandsEvent): void {
|
|
279
|
+
const toolName = (event.toolName as string | undefined) ?? "";
|
|
280
|
+
const toolInput = (event.toolInput as Record<string, unknown> | undefined) ?? {};
|
|
281
|
+
|
|
282
|
+
// Emit telemetry first (fail-open: policy check follows)
|
|
283
|
+
this.sink.emitToolInvoke(toolName, toolInput);
|
|
284
|
+
|
|
285
|
+
// Policy gate — native engine call (no subprocess)
|
|
286
|
+
const blockReason = this.policyGate.checkToolCall(toolName, toolInput);
|
|
287
|
+
if (blockReason) {
|
|
288
|
+
try {
|
|
289
|
+
event.cancel = blockReason;
|
|
290
|
+
} catch {
|
|
291
|
+
// Some event mock or future SDK change; ignore and continue
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** AfterToolCallEvent → postToolUse / tool.result */
|
|
297
|
+
onAfterToolCall(event: StrandsEvent): void {
|
|
298
|
+
const toolName = (event.toolName as string | undefined) ?? "";
|
|
299
|
+
const result = event.result;
|
|
300
|
+
this.sink.emitToolResult(toolName, result);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --------------------------------------------------------------------------
|
|
304
|
+
// Session start — emit agentSpawn when wiring is complete
|
|
305
|
+
// --------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
/** Call once after constructing / wiring to emit the agentSpawn event. */
|
|
308
|
+
emitSessionStart(): void {
|
|
309
|
+
this._sessionStartMs = Date.now();
|
|
310
|
+
this.sink.emitSessionStart();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kontourai/flow-agents-strands
|
|
3
|
+
*
|
|
4
|
+
* Native-import TypeScript adapter for AWS Strands Agents.
|
|
5
|
+
*
|
|
6
|
+
* Wires Flow Agents policy engine directly into Strands hook callbacks without
|
|
7
|
+
* spawning a subprocess. This is the first native-import consumer of the Flow
|
|
8
|
+
* Agents policy engine contract.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { FlowAgentsHooks } from "./hooks.js";
|
|
12
|
+
export type {
|
|
13
|
+
FlowAgentsHooksOptions,
|
|
14
|
+
HookRegistry,
|
|
15
|
+
StrandsEvent,
|
|
16
|
+
} from "./hooks.js";
|
|
17
|
+
|
|
18
|
+
export { TelemetrySink, STRANDS_TO_CANONICAL, normalizeToolName, SCHEMA_VERSION } from "./telemetry.js";
|
|
19
|
+
export type { TelemetrySinkOptions, TelemetryEvent } from "./telemetry.js";
|
|
20
|
+
|
|
21
|
+
export { PolicyGate, PROTECTED_FILES } from "./policy.js";
|
|
22
|
+
export type { PolicyGateOptions } from "./policy.js";
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy.ts — Native-import policy gate for BeforeToolCallEvent.
|
|
3
|
+
*
|
|
4
|
+
* Primary binding: NATIVE import of scripts/hooks/config-protection.js via
|
|
5
|
+
* its module.exports.run(raw, opts) API — no subprocess. This is the key
|
|
6
|
+
* differentiator from the Python adapter which uses a subprocess binding.
|
|
7
|
+
*
|
|
8
|
+
* The run() API contract (from run-hook.js §8.2, Form 2):
|
|
9
|
+
* run(rawJsonString, { truncated, maxStdin }) → string | { exitCode, stderr?, stdout? }
|
|
10
|
+
*
|
|
11
|
+
* Exit code / return value semantics:
|
|
12
|
+
* exitCode 0 → allow (return null)
|
|
13
|
+
* exitCode 2 → block (return block reason string from stderr/stdout)
|
|
14
|
+
* other/error → fail-open (return null)
|
|
15
|
+
*
|
|
16
|
+
* Engine location resolution (in priority order):
|
|
17
|
+
* 1. Constructor option `engineRoot` (explicit root dir — if provided but invalid,
|
|
18
|
+
* stops here and returns null; does NOT fall through to auto-discovery).
|
|
19
|
+
* 2. FLOW_AGENTS_ENGINE_ROOT env var.
|
|
20
|
+
* 3. Relative to this file: ../../../scripts/hooks/ (repo checkout layout).
|
|
21
|
+
* 4. Walk up from cwd: node_modules/@kontourai/flow-agents/scripts/hooks/.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { createRequire } from "node:module";
|
|
28
|
+
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = path.dirname(__filename);
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Protected files set — mirrors PROTECTED_FILES in config-protection.js
|
|
34
|
+
// Used as fallback when native engine is unavailable.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export const PROTECTED_FILES: ReadonlySet<string> = new Set([
|
|
38
|
+
// ESLint
|
|
39
|
+
".eslintrc",
|
|
40
|
+
".eslintrc.js",
|
|
41
|
+
".eslintrc.cjs",
|
|
42
|
+
".eslintrc.json",
|
|
43
|
+
".eslintrc.yml",
|
|
44
|
+
".eslintrc.yaml",
|
|
45
|
+
"eslint.config.js",
|
|
46
|
+
"eslint.config.mjs",
|
|
47
|
+
"eslint.config.cjs",
|
|
48
|
+
"eslint.config.ts",
|
|
49
|
+
"eslint.config.mts",
|
|
50
|
+
"eslint.config.cts",
|
|
51
|
+
// Prettier
|
|
52
|
+
".prettierrc",
|
|
53
|
+
".prettierrc.js",
|
|
54
|
+
".prettierrc.cjs",
|
|
55
|
+
".prettierrc.json",
|
|
56
|
+
".prettierrc.yml",
|
|
57
|
+
".prettierrc.yaml",
|
|
58
|
+
"prettier.config.js",
|
|
59
|
+
"prettier.config.cjs",
|
|
60
|
+
"prettier.config.mjs",
|
|
61
|
+
// Biome
|
|
62
|
+
"biome.json",
|
|
63
|
+
"biome.jsonc",
|
|
64
|
+
// Ruff
|
|
65
|
+
".ruff.toml",
|
|
66
|
+
"ruff.toml",
|
|
67
|
+
// Others
|
|
68
|
+
".shellcheckrc",
|
|
69
|
+
".stylelintrc",
|
|
70
|
+
".stylelintrc.json",
|
|
71
|
+
".stylelintrc.yml",
|
|
72
|
+
".markdownlint.json",
|
|
73
|
+
".markdownlint.yaml",
|
|
74
|
+
".markdownlintrc",
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
// Write-like tool names — only gate write-like tools; reads are always allowed.
|
|
78
|
+
const WRITE_TOOLS: ReadonlySet<string> = new Set([
|
|
79
|
+
"edit",
|
|
80
|
+
"write",
|
|
81
|
+
"fs_write",
|
|
82
|
+
"apply_patch",
|
|
83
|
+
"create_file",
|
|
84
|
+
"str_replace_editor",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
const BLOCK_REASON_TEMPLATE = (basename: string): string =>
|
|
88
|
+
`BLOCKED: Modifying ${basename} is not allowed. ` +
|
|
89
|
+
"Fix the source code to satisfy linter/formatter rules instead of " +
|
|
90
|
+
"weakening the config. If this is a legitimate config change, " +
|
|
91
|
+
"disable the config-protection policy gate temporarily.";
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Engine location resolver
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the hooksDir (directory containing config-protection.js etc).
|
|
99
|
+
*
|
|
100
|
+
* When engineRootOverride is provided:
|
|
101
|
+
* - If the override is a valid repo root (contains scripts/hooks/run-hook.js):
|
|
102
|
+
* return that hooksDir.
|
|
103
|
+
* - If the override is a valid hooks dir itself (contains run-hook.js directly):
|
|
104
|
+
* return that dir.
|
|
105
|
+
* - Otherwise: return null. Do NOT fall through to auto-discovery.
|
|
106
|
+
* This ensures an explicit but invalid override surfaces clearly.
|
|
107
|
+
*
|
|
108
|
+
* When engineRootOverride is absent:
|
|
109
|
+
* Auto-discover via env var → file-relative → cwd walk.
|
|
110
|
+
*/
|
|
111
|
+
function resolveHooksDir(engineRootOverride?: string): string | null {
|
|
112
|
+
// 1. Explicit override provided — do not fall through to auto-discovery
|
|
113
|
+
if (engineRootOverride !== undefined) {
|
|
114
|
+
const asRepoRoot = path.join(engineRootOverride, "scripts", "hooks");
|
|
115
|
+
if (fs.existsSync(path.join(asRepoRoot, "run-hook.js"))) {
|
|
116
|
+
return asRepoRoot;
|
|
117
|
+
}
|
|
118
|
+
// Allow engineRoot to point directly at scripts/hooks/
|
|
119
|
+
if (fs.existsSync(path.join(engineRootOverride, "run-hook.js"))) {
|
|
120
|
+
return engineRootOverride;
|
|
121
|
+
}
|
|
122
|
+
return null; // explicit override specified but invalid
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2. Env var
|
|
126
|
+
const envRoot = process.env.FLOW_AGENTS_ENGINE_ROOT;
|
|
127
|
+
if (envRoot) {
|
|
128
|
+
const candidate = path.join(envRoot, "scripts", "hooks");
|
|
129
|
+
if (fs.existsSync(path.join(candidate, "run-hook.js"))) return candidate;
|
|
130
|
+
if (fs.existsSync(path.join(envRoot, "run-hook.js"))) return envRoot;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3. Relative to this file (repo checkout layout)
|
|
134
|
+
// __dirname is dist/src/ at runtime, src/ when run as source
|
|
135
|
+
// scripts/hooks/ is relative to the repo root, not this file
|
|
136
|
+
for (const relPath of [
|
|
137
|
+
path.resolve(__dirname, "../../../scripts/hooks"), // src/ layout (3 up)
|
|
138
|
+
path.resolve(__dirname, "../../../../scripts/hooks"), // dist/src/ layout (4 up)
|
|
139
|
+
]) {
|
|
140
|
+
if (fs.existsSync(path.join(relPath, "run-hook.js"))) {
|
|
141
|
+
return relPath;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. Walk up from cwd looking for npm-installed package
|
|
146
|
+
let current = process.cwd();
|
|
147
|
+
for (let i = 0; i < 10; i++) {
|
|
148
|
+
const candidate = path.join(
|
|
149
|
+
current,
|
|
150
|
+
"node_modules",
|
|
151
|
+
"@kontourai",
|
|
152
|
+
"flow-agents",
|
|
153
|
+
"scripts",
|
|
154
|
+
"hooks"
|
|
155
|
+
);
|
|
156
|
+
if (fs.existsSync(path.join(candidate, "run-hook.js"))) {
|
|
157
|
+
return candidate;
|
|
158
|
+
}
|
|
159
|
+
const parent = path.dirname(current);
|
|
160
|
+
if (parent === current) break;
|
|
161
|
+
current = parent;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Native engine call (Form 2 — module.exports.run())
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
interface HookRunOutput {
|
|
172
|
+
exitCode?: number;
|
|
173
|
+
stderr?: string;
|
|
174
|
+
stdout?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type HookRunResult = string | HookRunOutput;
|
|
178
|
+
|
|
179
|
+
interface HookModule {
|
|
180
|
+
run(raw: string, options: { truncated: boolean; maxStdin: number }): HookRunResult;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function loadHookModule(hooksDir: string, scriptName: string): HookModule | null {
|
|
184
|
+
const scriptPath = path.join(hooksDir, scriptName);
|
|
185
|
+
if (!fs.existsSync(scriptPath)) return null;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
// Use createRequire for CJS modules from ESM context
|
|
189
|
+
const require = createRequire(import.meta.url);
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
191
|
+
const mod = require(scriptPath) as any;
|
|
192
|
+
if (mod && typeof mod.run === "function") {
|
|
193
|
+
return mod as HookModule;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function interpretRunResult(result: HookRunResult): { exitCode: number; blockReason: string | null } {
|
|
202
|
+
if (typeof result === "string") {
|
|
203
|
+
// String return = stdout pass-through → allow
|
|
204
|
+
return { exitCode: 0, blockReason: null };
|
|
205
|
+
}
|
|
206
|
+
if (result && typeof result === "object") {
|
|
207
|
+
const exitCode = typeof result.exitCode === "number" ? result.exitCode : 0;
|
|
208
|
+
if (exitCode === 2) {
|
|
209
|
+
const reason =
|
|
210
|
+
result.stderr?.trim() ||
|
|
211
|
+
result.stdout?.trim() ||
|
|
212
|
+
"BLOCKED: config-protection policy blocked this action.";
|
|
213
|
+
return { exitCode: 2, blockReason: reason };
|
|
214
|
+
}
|
|
215
|
+
return { exitCode, blockReason: null };
|
|
216
|
+
}
|
|
217
|
+
return { exitCode: 0, blockReason: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Pure-TS fallback — mirrors the JS config-protection.js logic
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
function tsConfigProtection(
|
|
225
|
+
toolName: string,
|
|
226
|
+
toolInput: Record<string, unknown>,
|
|
227
|
+
protectedFiles: ReadonlySet<string> = PROTECTED_FILES
|
|
228
|
+
): string | null {
|
|
229
|
+
if (!WRITE_TOOLS.has(toolName.toLowerCase())) return null;
|
|
230
|
+
|
|
231
|
+
const filePath =
|
|
232
|
+
(toolInput.path as string | undefined) ||
|
|
233
|
+
(toolInput.file_path as string | undefined) ||
|
|
234
|
+
"";
|
|
235
|
+
if (!filePath) return null;
|
|
236
|
+
|
|
237
|
+
const basename = path.basename(filePath);
|
|
238
|
+
if (protectedFiles.has(basename)) {
|
|
239
|
+
return BLOCK_REASON_TEMPLATE(basename);
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// PolicyGate
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
export interface PolicyGateOptions {
|
|
249
|
+
/**
|
|
250
|
+
* Root directory of the @kontourai/flow-agents package (the directory
|
|
251
|
+
* containing scripts/hooks/run-hook.js). Defaults to auto-discovery.
|
|
252
|
+
*
|
|
253
|
+
* If provided but invalid (engine not found there), the gate uses pure-TS
|
|
254
|
+
* fallback and does NOT attempt auto-discovery.
|
|
255
|
+
*/
|
|
256
|
+
engineRoot?: string;
|
|
257
|
+
/**
|
|
258
|
+
* Custom set of protected file basenames. When provided, the native engine
|
|
259
|
+
* is bypassed and pure-TS evaluation is used (intended for tests only).
|
|
260
|
+
*/
|
|
261
|
+
customProtectedFiles?: ReadonlySet<string>;
|
|
262
|
+
/**
|
|
263
|
+
* If true, suppress the one-time console.warn when falling back to TS evaluation.
|
|
264
|
+
*/
|
|
265
|
+
suppressFallbackWarning?: boolean;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export class PolicyGate {
|
|
269
|
+
private readonly configProtectionModule: HookModule | null;
|
|
270
|
+
private readonly customProtectedFiles: ReadonlySet<string> | undefined;
|
|
271
|
+
private readonly suppressFallbackWarning: boolean;
|
|
272
|
+
private _warnedFallback = false;
|
|
273
|
+
|
|
274
|
+
constructor(options: PolicyGateOptions = {}) {
|
|
275
|
+
this.customProtectedFiles = options.customProtectedFiles;
|
|
276
|
+
this.suppressFallbackWarning = options.suppressFallbackWarning ?? false;
|
|
277
|
+
|
|
278
|
+
const hooksDir = resolveHooksDir(options.engineRoot);
|
|
279
|
+
if (hooksDir) {
|
|
280
|
+
this.configProtectionModule = loadHookModule(hooksDir, "config-protection.js");
|
|
281
|
+
} else {
|
|
282
|
+
this.configProtectionModule = null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
get engineAvailable(): boolean {
|
|
287
|
+
return this.configProtectionModule !== null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private warnFallback(): void {
|
|
291
|
+
if (this._warnedFallback || this.suppressFallbackWarning) return;
|
|
292
|
+
this._warnedFallback = true;
|
|
293
|
+
console.warn(
|
|
294
|
+
"[flow-agents-strands] Warning: The Flow Agents policy engine (config-protection.js) " +
|
|
295
|
+
"could not be loaded via native import. Policy gates are degrading to the built-in " +
|
|
296
|
+
"TypeScript fallback (fail-open for unknown cases). " +
|
|
297
|
+
"Set FLOW_AGENTS_ENGINE_ROOT to the @kontourai/flow-agents root directory to " +
|
|
298
|
+
"use the canonical engine. See README.md §Limitations."
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Check whether a tool call is allowed by config-protection policy.
|
|
304
|
+
*
|
|
305
|
+
* Returns null if allowed, or a block-reason string if blocked.
|
|
306
|
+
* Always fail-open on errors.
|
|
307
|
+
*/
|
|
308
|
+
checkToolCall(
|
|
309
|
+
toolName: string,
|
|
310
|
+
toolInput: Record<string, unknown>
|
|
311
|
+
): string | null {
|
|
312
|
+
// Tool-name pre-filter: reads are always allowed
|
|
313
|
+
if (!WRITE_TOOLS.has(toolName.toLowerCase())) return null;
|
|
314
|
+
|
|
315
|
+
// Custom protected set → pure-TS evaluation only
|
|
316
|
+
if (this.customProtectedFiles !== undefined) {
|
|
317
|
+
return tsConfigProtection(toolName, toolInput, this.customProtectedFiles);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Native engine call (the key differentiator vs Python subprocess adapter)
|
|
321
|
+
if (this.configProtectionModule !== null) {
|
|
322
|
+
const payload = JSON.stringify({
|
|
323
|
+
hook_event_name: "PreToolUse",
|
|
324
|
+
tool_name: toolName,
|
|
325
|
+
tool_input: toolInput,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const result = this.configProtectionModule.run(payload, {
|
|
330
|
+
truncated: false,
|
|
331
|
+
maxStdin: 1024 * 1024,
|
|
332
|
+
});
|
|
333
|
+
const { blockReason } = interpretRunResult(result);
|
|
334
|
+
return blockReason;
|
|
335
|
+
} catch {
|
|
336
|
+
// fail-open on native call errors
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Fallback: pure-TS evaluation
|
|
342
|
+
this.warnFallback();
|
|
343
|
+
return tsConfigProtection(toolName, toolInput);
|
|
344
|
+
}
|
|
345
|
+
}
|