@crewhaus/tool-message-channel 0.1.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/package.json +51 -0
- package/src/index.test.ts +174 -0
- package/src/index.ts +105 -0
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-message-channel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Built-in `SendMessage` tool for cross-channel addressing (Section 12). Permission-gated; fail-closed without an explicit allow rule.",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/channel-adapter-slack": "0.0.0",
|
|
16
|
+
"@crewhaus/errors": "0.0.0",
|
|
17
|
+
"@crewhaus/tool-builder": "0.0.0",
|
|
18
|
+
"@crewhaus/tool-catalog": "0.0.0",
|
|
19
|
+
"zod": "^3.23.8"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@crewhaus/permission-engine": "0.0.0",
|
|
23
|
+
"@crewhaus/tool-executor": "0.0.0",
|
|
24
|
+
"@crewhaus/tool-permission-matcher": "0.0.0",
|
|
25
|
+
"@crewhaus/tool-validate": "0.0.0"
|
|
26
|
+
},
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Max Meier",
|
|
30
|
+
"email": "max@studiomax.io",
|
|
31
|
+
"url": "https://studiomax.io"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
36
|
+
"directory": "packages/tool-message-channel"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-message-channel#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "restricted"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"src",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE",
|
|
49
|
+
"NOTICE"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type ChannelAdapter,
|
|
4
|
+
type InboundEvent,
|
|
5
|
+
createSlackAdapter,
|
|
6
|
+
} from "@crewhaus/channel-adapter-slack";
|
|
7
|
+
import {
|
|
8
|
+
BUILTIN_DEFAULT_RULES,
|
|
9
|
+
type RuleSet,
|
|
10
|
+
emptyRuleSet,
|
|
11
|
+
evaluate,
|
|
12
|
+
} from "@crewhaus/permission-engine";
|
|
13
|
+
import { executeTool } from "@crewhaus/tool-executor";
|
|
14
|
+
import { _resetChannelAdapters, registerChannelAdapter, sendMessage } from "./index";
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
_resetChannelAdapters();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function dummyAdapter(): {
|
|
21
|
+
adapter: ChannelAdapter;
|
|
22
|
+
captured: Array<{ event: InboundEvent; text: string }>;
|
|
23
|
+
} {
|
|
24
|
+
const captured: Array<{ event: InboundEvent; text: string }> = [];
|
|
25
|
+
const adapter: ChannelAdapter = {
|
|
26
|
+
id: "slack",
|
|
27
|
+
verify: () => true,
|
|
28
|
+
parseInbound: () => ({ kind: "skip" }),
|
|
29
|
+
sendReply: async (a) => {
|
|
30
|
+
captured.push(a);
|
|
31
|
+
},
|
|
32
|
+
setTyping: async () => undefined,
|
|
33
|
+
};
|
|
34
|
+
return { adapter, captured };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("sendMessage tool — shape (T1)", () => {
|
|
38
|
+
test("declares destructive: true so default mode evaluates fail-closed", () => {
|
|
39
|
+
expect(sendMessage.name).toBe("SendMessage");
|
|
40
|
+
expect(sendMessage.destructive).toBe(true);
|
|
41
|
+
expect(sendMessage.readOnly).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("sendMessage permission gating (T8 — security)", () => {
|
|
46
|
+
test("evaluates to ASK in default mode without an explicit allow rule", () => {
|
|
47
|
+
const decision = evaluate(
|
|
48
|
+
{
|
|
49
|
+
toolName: "SendMessage",
|
|
50
|
+
input: { channel: "slack:T:C", text: "x" },
|
|
51
|
+
readOnly: false,
|
|
52
|
+
destructive: true,
|
|
53
|
+
},
|
|
54
|
+
"default",
|
|
55
|
+
{ ...emptyRuleSet, builtin: BUILTIN_DEFAULT_RULES },
|
|
56
|
+
);
|
|
57
|
+
// ASK with no askApproval prompter => the runtime treats it as deny.
|
|
58
|
+
// The point is: it does NOT auto-allow. (alwaysAllow Read is in builtins
|
|
59
|
+
// but never matches SendMessage.)
|
|
60
|
+
expect(decision).toBe("ask");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("evaluates to ASK in auto mode (destructive default)", () => {
|
|
64
|
+
const decision = evaluate(
|
|
65
|
+
{ toolName: "SendMessage", input: {}, readOnly: false, destructive: true },
|
|
66
|
+
"auto",
|
|
67
|
+
{ ...emptyRuleSet, builtin: BUILTIN_DEFAULT_RULES },
|
|
68
|
+
);
|
|
69
|
+
expect(decision).toBe("ask");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("plan mode denies SendMessage outright (not readOnly)", () => {
|
|
73
|
+
const decision = evaluate(
|
|
74
|
+
{ toolName: "SendMessage", input: {}, readOnly: false, destructive: true },
|
|
75
|
+
"plan",
|
|
76
|
+
{ ...emptyRuleSet, builtin: BUILTIN_DEFAULT_RULES },
|
|
77
|
+
);
|
|
78
|
+
expect(decision).toBe("deny");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("explicit alwaysAllow rule unlocks SendMessage", () => {
|
|
82
|
+
const rules: RuleSet = {
|
|
83
|
+
...emptyRuleSet,
|
|
84
|
+
yaml: [{ type: "alwaysAllow", pattern: "SendMessage", source: "yaml" }],
|
|
85
|
+
builtin: BUILTIN_DEFAULT_RULES,
|
|
86
|
+
};
|
|
87
|
+
const decision = evaluate(
|
|
88
|
+
{ toolName: "SendMessage", input: {}, readOnly: false, destructive: true },
|
|
89
|
+
"default",
|
|
90
|
+
rules,
|
|
91
|
+
);
|
|
92
|
+
expect(decision).toBe("allow");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("alwaysDeny rule wins over alwaysAllow due to source priority", () => {
|
|
96
|
+
const rules: RuleSet = {
|
|
97
|
+
...emptyRuleSet,
|
|
98
|
+
flag: [{ type: "alwaysDeny", pattern: "SendMessage", source: "flag" }],
|
|
99
|
+
yaml: [{ type: "alwaysAllow", pattern: "SendMessage", source: "yaml" }],
|
|
100
|
+
};
|
|
101
|
+
const decision = evaluate(
|
|
102
|
+
{ toolName: "SendMessage", input: {}, readOnly: false, destructive: true },
|
|
103
|
+
"default",
|
|
104
|
+
rules,
|
|
105
|
+
);
|
|
106
|
+
expect(decision).toBe("deny");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("sendMessage execution", () => {
|
|
111
|
+
test("dispatches to the registered adapter and returns success", async () => {
|
|
112
|
+
const { adapter, captured } = dummyAdapter();
|
|
113
|
+
registerChannelAdapter("slack", adapter);
|
|
114
|
+
const result = await executeTool(
|
|
115
|
+
sendMessage,
|
|
116
|
+
{ channel: "slack:T9:C7:1700000000.000", text: "hi from tool" },
|
|
117
|
+
{ toolUseId: "u1" },
|
|
118
|
+
);
|
|
119
|
+
expect(result.isError).toBe(false);
|
|
120
|
+
expect(result.content).toBe("sent to slack:T9:C7:1700000000.000");
|
|
121
|
+
expect(captured).toHaveLength(1);
|
|
122
|
+
expect(captured[0]?.event.workspaceId).toBe("T9");
|
|
123
|
+
expect(captured[0]?.event.channelId).toBe("C7");
|
|
124
|
+
expect(captured[0]?.event.threadTs).toBe("1700000000.000");
|
|
125
|
+
expect(captured[0]?.text).toBe("hi from tool");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("returns isError when no adapter is registered", async () => {
|
|
129
|
+
const result = await executeTool(
|
|
130
|
+
sendMessage,
|
|
131
|
+
{ channel: "slack:T:C:1.0", text: "x" },
|
|
132
|
+
{ toolUseId: "u2" },
|
|
133
|
+
);
|
|
134
|
+
expect(result.isError).toBe(true);
|
|
135
|
+
expect(result.content).toMatch(/no adapter registered/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("returns isError on a malformed routing key", async () => {
|
|
139
|
+
const result = await executeTool(
|
|
140
|
+
sendMessage,
|
|
141
|
+
{ channel: "slack-only", text: "x" },
|
|
142
|
+
{ toolUseId: "u3" },
|
|
143
|
+
);
|
|
144
|
+
expect(result.isError).toBe(true);
|
|
145
|
+
expect(result.content).toMatch(/invalid channel/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("integrates with createSlackAdapter for end-to-end dispatch", async () => {
|
|
149
|
+
const fetchCalls: Array<{ url: string; body: string }> = [];
|
|
150
|
+
const fakeFetch: typeof fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
151
|
+
fetchCalls.push({
|
|
152
|
+
url: typeof input === "string" ? input : input.toString(),
|
|
153
|
+
body: typeof init?.body === "string" ? init.body : "",
|
|
154
|
+
});
|
|
155
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
156
|
+
status: 200,
|
|
157
|
+
headers: { "content-type": "application/json" },
|
|
158
|
+
});
|
|
159
|
+
}) as unknown as typeof fetch;
|
|
160
|
+
const adapter = createSlackAdapter(
|
|
161
|
+
{ botToken: "xoxb", signingSecret: "s" },
|
|
162
|
+
{ apiBaseUrl: "https://slack.test/api", fetch: fakeFetch },
|
|
163
|
+
);
|
|
164
|
+
registerChannelAdapter("slack", adapter);
|
|
165
|
+
const result = await executeTool(
|
|
166
|
+
sendMessage,
|
|
167
|
+
{ channel: "slack:T:C:1.0", text: "ok" },
|
|
168
|
+
{ toolUseId: "u4" },
|
|
169
|
+
);
|
|
170
|
+
expect(result.isError).toBe(false);
|
|
171
|
+
expect(fetchCalls).toHaveLength(1);
|
|
172
|
+
expect(fetchCalls[0]?.url).toBe("https://slack.test/api/chat.postMessage");
|
|
173
|
+
});
|
|
174
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ChannelAdapter, InboundEvent } from "@crewhaus/channel-adapter-slack";
|
|
2
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
3
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
4
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Catalog R4 `tool-message-channel` — `SendMessage(channel, text)` for
|
|
9
|
+
* cross-channel addressing. Section 12.
|
|
10
|
+
*
|
|
11
|
+
* `channel` is an opaque routing key (the adapter id is the prefix before
|
|
12
|
+
* the first `:`, e.g. `slack:T123:C456:1700000000.000`). The tool dispatches
|
|
13
|
+
* to the matching adapter registered via `registerChannelAdapter` at boot
|
|
14
|
+
* (the codegen-emitted `daemon.ts` calls this after `createSlackAdapter`).
|
|
15
|
+
*
|
|
16
|
+
* SECURITY: declared `destructive: true` so the permission engine treats it
|
|
17
|
+
* as fail-closed in `default` mode — the agent can only invoke `SendMessage`
|
|
18
|
+
* when an explicit `alwaysAllow` rule names it. The hello-channel example
|
|
19
|
+
* documents this contract.
|
|
20
|
+
*/
|
|
21
|
+
export class ChannelToolError extends CrewhausError {
|
|
22
|
+
override readonly name = "ChannelToolError";
|
|
23
|
+
constructor(message: string, cause?: unknown) {
|
|
24
|
+
super("tool", message, cause);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const adapters = new Map<string, ChannelAdapter>();
|
|
29
|
+
|
|
30
|
+
export function registerChannelAdapter(id: string, adapter: ChannelAdapter): void {
|
|
31
|
+
adapters.set(id, adapter);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getChannelAdapter(id: string): ChannelAdapter | undefined {
|
|
35
|
+
return adapters.get(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Test-only — clears the per-process registry between tests. */
|
|
39
|
+
export function _resetChannelAdapters(): void {
|
|
40
|
+
adapters.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse a routing key into an adapter id + the InboundEvent fields needed
|
|
45
|
+
* by `sendReply`. The format is intentionally flat and human-typeable so
|
|
46
|
+
* the model can construct routing keys from prior tool results.
|
|
47
|
+
*
|
|
48
|
+
* Slack format: `slack:<workspaceId>:<channelId>[:<threadTs>]`
|
|
49
|
+
* Future channels follow the same shape under their own adapter id.
|
|
50
|
+
*/
|
|
51
|
+
function parseRoutingKey(channel: string): { adapterId: string; event: InboundEvent } {
|
|
52
|
+
const parts = channel.split(":");
|
|
53
|
+
if (parts.length < 3) {
|
|
54
|
+
throw new ChannelToolError(
|
|
55
|
+
`SendMessage: invalid channel "${channel}" — expected "<adapterId>:<workspaceId>:<channelId>[:<threadTs>]"`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const adapterId = parts[0] ?? "";
|
|
59
|
+
const workspaceId = parts[1] ?? "";
|
|
60
|
+
const channelId = parts[2] ?? "";
|
|
61
|
+
const threadTs = parts[3];
|
|
62
|
+
return {
|
|
63
|
+
adapterId,
|
|
64
|
+
event: {
|
|
65
|
+
idempotencyKey: `synthetic_${Date.now()}`,
|
|
66
|
+
workspaceId,
|
|
67
|
+
channelId,
|
|
68
|
+
userId: "<bot>",
|
|
69
|
+
...(threadTs !== undefined ? { threadTs } : {}),
|
|
70
|
+
ts: threadTs ?? "0",
|
|
71
|
+
text: "",
|
|
72
|
+
subtype: "message" as const,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sendMessageSchema = z.object({
|
|
78
|
+
channel: z.string().min(1),
|
|
79
|
+
text: z.string().min(1),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const sendMessage: RegisteredTool = buildTool({
|
|
83
|
+
name: "SendMessage",
|
|
84
|
+
description:
|
|
85
|
+
'Send a message to a channel/thread/DM identified by a routing key (e.g. "slack:T123:C456:1700000000.000"). Permission-gated.',
|
|
86
|
+
inputSchema: sendMessageSchema,
|
|
87
|
+
destructive: true,
|
|
88
|
+
// Pillar 3 sink-side: channel send is the textbook lateral-comm sink.
|
|
89
|
+
// egress-classifier scans the `text` payload for cross-origin lineage.
|
|
90
|
+
scope: "external",
|
|
91
|
+
// Pillar 3 intent gate: sending a message is irreversible-ish and visible
|
|
92
|
+
// to humans on the other end, so demand justification.
|
|
93
|
+
requireJustification: true,
|
|
94
|
+
execute: async (input) => {
|
|
95
|
+
const { adapterId, event } = parseRoutingKey(input.channel);
|
|
96
|
+
const adapter = getChannelAdapter(adapterId);
|
|
97
|
+
if (!adapter) {
|
|
98
|
+
throw new ChannelToolError(
|
|
99
|
+
`SendMessage: no adapter registered for "${adapterId}" — known: [${[...adapters.keys()].join(", ") || "<none>"}]`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
await adapter.sendReply({ event, text: input.text });
|
|
103
|
+
return `sent to ${input.channel}`;
|
|
104
|
+
},
|
|
105
|
+
});
|