@crowdstrike/falcon-guardian-opencode-collector 0.0.0-stage → 1.0.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/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 CrowdStrike
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ # falcon-guardian-opencode-collector
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Falcon Guardian collector for OpenCode.
4
+
5
+ ## Setup
6
+
7
+ Set the following environment variables:
8
+
9
+ - `CS_AIDR_TOKEN`: Falcon Guardian API token.
10
+ - `CS_AIDR_BASE_URL`: Falcon Guardian API base URL.
11
+
12
+ Then restart the OpenCode background server:
13
+
14
+ ```
15
+ opencode service restart
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ Load the plugin in `opencode.json(c)`:
21
+
22
+ ```jsonc
23
+ {
24
+ "plugins": [
25
+ {
26
+ "package": "@crowdstrike/falcon-guardian-opencode-collector",
27
+ "options": { "failureMode": "closed" },
28
+ },
29
+ ],
30
+ }
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ | Option | Env | Default |
36
+ |---|---|---|
37
+ | `token` | `CS_AIDR_TOKEN` | required |
38
+ | `baseURL` | `CS_AIDR_BASE_URL` | `https://api.crowdstrike.com/aidr/aiguard` |
39
+ | `timeout` (seconds) | | `30` |
40
+ | `failureMode` (`open` or `closed`) | | `closed` |
41
+
42
+ ## Develop
43
+
44
+ ```sh
45
+ pnpm install
46
+ pnpm test
47
+ pnpm lint && pnpm typecheck
48
+ pnpm build
49
+ ```
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@crowdstrike/falcon-guardian-opencode-collector",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0",
4
+ "description": "Falcon Guardian collector for OpenCode",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/CrowdStrike/falcon-guardian-opencode-collector.git"
12
+ },
13
+ "type": "module",
14
+ "files": [
15
+ "src",
16
+ "LICENSE.txt",
17
+ "README.md"
18
+ ],
19
+ "exports": {
20
+ ".": "./src/index.ts"
21
+ },
22
+ "dependencies": {
23
+ "@crowdstrike/aidr": "^1.3.0",
24
+ "@opencode/plugin": "^2.0.32",
25
+ "@opencode/schema": "^2.0.32",
26
+ "plist": "^5.0.0",
27
+ "safe-execa": "^0.3.0"
28
+ },
29
+ "peerDependencies": {
30
+ "effect": "^4.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@biomejs/biome": "2.4.6",
34
+ "@containerbase/semantic-release-pnpm": "1.4.3",
35
+ "@tsconfig/node22": "22.0.6",
36
+ "@types/node": "22.19.17",
37
+ "conventional-changelog-conventionalcommits": "9.3.1",
38
+ "effect": "4.0.1",
39
+ "semantic-release": "25.0.9",
40
+ "typescript": "5.9.3"
41
+ },
42
+ "scripts": {
43
+ "lint": "biome check .",
44
+ "lint:fix": "biome check --write .",
45
+ "test": "node --test 'src/**/*.test.ts'",
46
+ "typecheck": "tsc --noEmit"
47
+ }
6
48
  }
@@ -0,0 +1,375 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { Agent } from '@opencode/schema/agent';
4
+ import { Model } from '@opencode/schema/model';
5
+ import { Provider } from '@opencode/schema/provider';
6
+ import { Session } from '@opencode/schema/session';
7
+ import { SessionMessage } from '@opencode/schema/session-message';
8
+ import { Tool } from '@opencode/schema/tool';
9
+ import * as guard from './guard.ts';
10
+
11
+ const meta: guard.Meta = {
12
+ user: 'dennis',
13
+ directory: '/w',
14
+ version: '2.0.26',
15
+ processID: 4242,
16
+ model: {
17
+ providerID: Provider.ID.make('anthropic'),
18
+ id: Model.ID.make('claude-sonnet-5'),
19
+ },
20
+ sensorAID: 'd4208f3f3cf544f196bfe612aa8035fe',
21
+ };
22
+
23
+ const ids = {
24
+ sessionID: Session.ID.make('ses_1'),
25
+ agent: Agent.ID.make('build'),
26
+ messageID: SessionMessage.ID.make('msg_1'),
27
+ id: Tool.CallID.make('call_1'),
28
+ };
29
+
30
+ const prompt = (): guard.PromptEvent => ({
31
+ sessionID: ids.sessionID,
32
+ messageID: ids.messageID,
33
+ prompt: {
34
+ text: 'read @notes.md, my SSN is 900-12-3456',
35
+ files: [
36
+ {
37
+ uri: 'file:///w/notes.md',
38
+ mention: { start: 5, end: 14, text: '@notes.md' },
39
+ },
40
+ ],
41
+ },
42
+ delivery: 'steer',
43
+ });
44
+
45
+ const toolInput = (): guard.ToolInputEvent => ({
46
+ ...ids,
47
+ tool: 'write',
48
+ input: { filePath: '/w/a.txt', content: '900-12-3456' },
49
+ });
50
+
51
+ const toolOutput = (result: Tool.Result): guard.ToolOutputEvent => ({
52
+ ...ids,
53
+ tool: 'shell',
54
+ input: { command: 'cat a.txt' },
55
+ status: 'completed',
56
+ result,
57
+ });
58
+
59
+ /** A request as sent on the wire. */
60
+ const wire = (req: object): unknown => JSON.parse(JSON.stringify(req));
61
+
62
+ const blocked = {
63
+ summary: 'SSN was detected and blocked.',
64
+ result: { blocked: true },
65
+ };
66
+ const redacted = (...messages: object[]) => ({
67
+ result: { transformed: true, guard_output: { messages } },
68
+ });
69
+
70
+ test('token and base URL come from options, then the environment', () => {
71
+ const env = {
72
+ CS_AIDR_TOKEN: 'env-token',
73
+ CS_AIDR_BASE_URL: 'https://api.us-2.crowdstrike.com/aidr/aiguard',
74
+ };
75
+ assert.deepEqual(guard.config({}, env), {
76
+ token: 'env-token',
77
+ baseURL: 'https://api.us-2.crowdstrike.com/aidr/aiguard',
78
+ timeoutMillis: 30_000,
79
+ failureMode: 'closed',
80
+ });
81
+ assert.deepEqual(
82
+ guard.config(
83
+ {
84
+ token: 'opt',
85
+ baseURL: 'https://api.collector-crowdstrike.com',
86
+ timeout: 5,
87
+ failureMode: 'open',
88
+ },
89
+ env
90
+ ),
91
+ {
92
+ token: 'opt',
93
+ baseURL: 'https://api.collector-crowdstrike.com',
94
+ timeoutMillis: 5_000,
95
+ failureMode: 'open',
96
+ }
97
+ );
98
+ });
99
+
100
+ test('a base URL off CrowdStrike HTTPS hosts leaves no token', () => {
101
+ for (const baseURL of [
102
+ 'https://evil.com',
103
+ 'https://evilcrowdstrike.com',
104
+ 'https://crowdstrike.com.evil.com',
105
+ 'https://api.crowdstrike.com@evil.com',
106
+ 'https://evil.com\\.crowdstrike.com',
107
+ 'http://api.crowdstrike.com',
108
+ 'not a url',
109
+ ])
110
+ assert.ok(
111
+ guard.config({ baseURL }, { CS_AIDR_TOKEN: 't' }).token instanceof Error,
112
+ baseURL
113
+ );
114
+ });
115
+
116
+ test('a missing token or invalid options leave no token', () => {
117
+ assert.ok(guard.config({}, {}).token instanceof Error);
118
+ const invalid = guard.config(
119
+ { token: 1, failureMode: 'open' },
120
+ { CS_AIDR_TOKEN: 't' }
121
+ );
122
+ assert.ok(invalid.token instanceof Error);
123
+ assert.equal(invalid.failureMode, 'closed');
124
+ assert.equal(
125
+ guard.config({ failureMode: 'shut' }, { CS_AIDR_TOKEN: 't' }).failureMode,
126
+ 'closed'
127
+ );
128
+ });
129
+
130
+ test('prompt is sent as input', () => {
131
+ assert.deepEqual(wire(guard.promptRequest(prompt(), meta)), {
132
+ guard_input: {
133
+ messages: [
134
+ { role: 'user', content: 'read @notes.md, my SSN is 900-12-3456' },
135
+ ],
136
+ },
137
+ event_type: 'input',
138
+ llm_provider: 'anthropic',
139
+ model: 'claude-sonnet-5',
140
+ session_id: 'ses_1',
141
+ user_id: 'dennis',
142
+ agent_type: 'code-assistant',
143
+ is_agent: true,
144
+ process_id: '4242',
145
+ process_tags: ['213584428666668'],
146
+ extra_info: {
147
+ app_name: 'OpenCode',
148
+ app_version: '2.0.26',
149
+ user_name: 'dennis',
150
+ opencode_hook_name: 'session.prompt',
151
+ opencode_directory: '/w',
152
+ sensor_aid: 'd4208f3f3cf544f196bfe612aa8035fe',
153
+ },
154
+ });
155
+ });
156
+
157
+ test('Windows sensor AID is the AG value as lowercase hex', () => {
158
+ const sim =
159
+ 'HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\services\\CSAgent\\Sim\r\n';
160
+ assert.equal(
161
+ guard.windowsSensorAID(
162
+ `\r\n${sim} AFLAGS REG_BINARY 00000000\r\n` +
163
+ ' AG REG_BINARY D4208F3F3CF544F196BFE612AA8035FE\r\n\r\n' +
164
+ 'End of search: 2 match(es) found.\r\n'
165
+ ),
166
+ 'd4208f3f3cf544f196bfe612aa8035fe'
167
+ );
168
+ assert.equal(
169
+ guard.windowsSensorAID(`${sim} AG REG_BINARY D4208F3F\r\n`),
170
+ undefined
171
+ );
172
+ assert.equal(guard.windowsSensorAID(''), undefined);
173
+ });
174
+
175
+ test('macOS sensor AID is the plist aid as lowercase hex', () => {
176
+ const info = (aid: string) => `<?xml version="1.0" encoding="UTF-8"?>
177
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
178
+ <plist version="1.0">
179
+ <dict>
180
+ <key>aid</key>
181
+ ${aid}
182
+ <key>cid</key>
183
+ <string>6CB9530D078E415F96CE34BE06631A37</string>
184
+ <key>sensor_loaded</key>
185
+ <true/>
186
+ </dict>
187
+ </plist>
188
+ `;
189
+ assert.equal(
190
+ guard.macSensorAID(
191
+ info('<string>D4208F3F3CF544F196BFE612AA8035FE</string>')
192
+ ),
193
+ 'd4208f3f3cf544f196bfe612aa8035fe'
194
+ );
195
+ assert.equal(
196
+ guard.macSensorAID(info('<string>D4208F3F</string>')),
197
+ undefined
198
+ );
199
+ assert.equal(guard.macSensorAID(info('<true/>')), undefined);
200
+ assert.equal(guard.macSensorAID('falconctl: permission denied'), undefined);
201
+ assert.equal(guard.macSensorAID(''), undefined);
202
+ });
203
+
204
+ test('tool input is sent as JSON-encoded arguments', () => {
205
+ const bare = { ...meta, model: undefined, sensorAID: undefined };
206
+ const req = guard.toolInputRequest(toolInput(), bare);
207
+ assert.deepEqual(wire(req), {
208
+ ...(wire(guard.promptRequest(prompt(), bare)) as object),
209
+ event_type: 'tool_input',
210
+ guard_input: {
211
+ messages: [
212
+ {
213
+ role: 'assistant',
214
+ content: null,
215
+ tool_calls: [
216
+ {
217
+ id: 'call_1',
218
+ type: 'function',
219
+ function: {
220
+ name: 'write',
221
+ arguments: '{"filePath":"/w/a.txt","content":"900-12-3456"}',
222
+ },
223
+ },
224
+ ],
225
+ },
226
+ ],
227
+ },
228
+ extra_info: {
229
+ app_name: 'OpenCode',
230
+ app_version: '2.0.26',
231
+ user_name: 'dennis',
232
+ opencode_hook_name: 'tool.execute.before',
233
+ opencode_directory: '/w',
234
+ opencode_tool_name: 'write',
235
+ opencode_agent: 'build',
236
+ },
237
+ });
238
+ });
239
+
240
+ test('tool output is sent as the text the model reads', () => {
241
+ const sent = (result: Tool.Result) =>
242
+ (
243
+ wire(guard.toolOutputRequest(toolOutput(result), meta)) as {
244
+ guard_input: { messages: unknown[] };
245
+ }
246
+ ).guard_input.messages[1];
247
+ const message = (content: string) => ({
248
+ role: 'tool',
249
+ tool_call_id: 'call_1',
250
+ content,
251
+ });
252
+ assert.deepEqual(
253
+ sent({ content: '900-12-3456\n' }),
254
+ message('900-12-3456\n')
255
+ );
256
+ assert.deepEqual(
257
+ sent({
258
+ content: [
259
+ { type: 'text', text: 'a' },
260
+ { type: 'file', uri: 'data:image/png;base64,', mime: 'image/png' },
261
+ { type: 'text', text: 'b' },
262
+ ],
263
+ }),
264
+ message('a\nb')
265
+ );
266
+ assert.deepEqual(
267
+ sent({ output: { exitCode: 0 } }),
268
+ message('{"exitCode":0}')
269
+ );
270
+ });
271
+
272
+ test('verdicts', () => {
273
+ assert.deepEqual(
274
+ guard.verdict({ result: { blocked: false, transformed: false } }),
275
+ { type: 'allow' }
276
+ );
277
+ assert.deepEqual(guard.verdict(blocked), {
278
+ type: 'block',
279
+ reason: 'Falcon Guardian: SSN was detected and blocked.',
280
+ });
281
+ assert.throws(() => guard.verdict({ result: { transformed: true } }));
282
+ assert.throws(() => guard.verdict(redacted()));
283
+ });
284
+
285
+ test('failure mode decides when there is no verdict', () => {
286
+ assert.deepEqual(guard.failure('open', 'timeout'), { type: 'allow' });
287
+ assert.deepEqual(guard.failure('closed', 'timeout'), {
288
+ type: 'block',
289
+ reason: 'Falcon Guardian: timeout',
290
+ });
291
+ });
292
+
293
+ test('allowed events are unchanged', () => {
294
+ const allow = { type: 'allow' } as const;
295
+ const [p, i, o] = [prompt(), toolInput(), toolOutput({ content: 'x' })];
296
+ assert.equal(guard.applyToPrompt(p, allow), undefined);
297
+ assert.equal(guard.applyToToolInput(i, allow), undefined);
298
+ assert.equal(guard.applyToToolOutput(o, allow), undefined);
299
+ assert.deepEqual(
300
+ [p, i, o],
301
+ [prompt(), toolInput(), toolOutput({ content: 'x' })]
302
+ );
303
+ });
304
+
305
+ test('blocked events are rejected with the reason, unchanged', () => {
306
+ const block = guard.verdict(blocked);
307
+ const reason = 'Falcon Guardian: SSN was detected and blocked.';
308
+ const [p, i, o] = [prompt(), toolInput(), toolOutput({ content: 'x' })];
309
+ assert.equal(guard.applyToPrompt(p, block), reason);
310
+ assert.equal(guard.applyToToolInput(i, block), reason);
311
+ assert.equal(guard.applyToToolOutput(o, block), reason);
312
+ assert.deepEqual(
313
+ [p, i, o],
314
+ [prompt(), toolInput(), toolOutput({ content: 'x' })]
315
+ );
316
+ });
317
+
318
+ test('redacted prompt keeps attachments without mentions', () => {
319
+ const event = prompt();
320
+ guard.applyToPrompt(
321
+ event,
322
+ guard.verdict(
323
+ redacted({ role: 'user', content: 'read @notes.md, my SSN is <US_SSN>' })
324
+ )
325
+ );
326
+ assert.deepEqual(wire(event.prompt), {
327
+ text: 'read @notes.md, my SSN is <US_SSN>',
328
+ files: [{ uri: 'file:///w/notes.md' }],
329
+ });
330
+ });
331
+
332
+ test('redacted tool input rewrites the call', () => {
333
+ const event = toolInput();
334
+ const res = redacted({
335
+ role: 'assistant',
336
+ content: null,
337
+ tool_calls: [
338
+ {
339
+ id: 'call_1',
340
+ type: 'function',
341
+ function: {
342
+ name: 'write',
343
+ arguments: '{"filePath":"/w/a.txt","content":"<US_SSN>"}',
344
+ },
345
+ },
346
+ ],
347
+ });
348
+ assert.equal(guard.applyToToolInput(event, guard.verdict(res)), undefined);
349
+ assert.deepEqual(event.input, { filePath: '/w/a.txt', content: '<US_SSN>' });
350
+ });
351
+
352
+ test('redacted tool output replaces the output', () => {
353
+ const event = toolOutput({
354
+ output: { stdout: '900-12-3456' },
355
+ content: '900-12-3456',
356
+ });
357
+ const res = redacted(
358
+ { role: 'assistant', tool_calls: [] },
359
+ { role: 'tool', tool_call_id: 'call_1', content: '<US_SSN>' }
360
+ );
361
+ guard.applyToToolOutput(event, guard.verdict(res));
362
+ assert.deepEqual(event.result, { content: '<US_SSN>' });
363
+ });
364
+
365
+ test('redaction of the wrong message kind is an error', () => {
366
+ const user = guard.verdict(redacted({ role: 'user', content: 'x' }));
367
+ assert.throws(() => guard.applyToToolInput(toolInput(), user));
368
+ assert.throws(() =>
369
+ guard.applyToToolOutput(toolOutput({ content: 'x' }), user)
370
+ );
371
+ const tool = guard.verdict(
372
+ redacted({ role: 'tool', tool_call_id: 'call_1', content: 'x' })
373
+ );
374
+ assert.throws(() => guard.applyToPrompt(prompt(), tool));
375
+ });
package/src/guard.ts ADDED
@@ -0,0 +1,347 @@
1
+ import type {
2
+ AidrPostV1GuardChatCompletionsResponses,
3
+ ChatCompletionsGuard,
4
+ } from '@crowdstrike/aidr';
5
+ import type { Model } from '@opencode/plugin/effect';
6
+ import type { SessionPrompt } from '@opencode/plugin/effect/session';
7
+ import type { ToolHooks } from '@opencode/plugin/effect/tool';
8
+ import type { Tool } from '@opencode/schema/tool';
9
+ import { Exit, Schema } from 'effect';
10
+ import * as plist from 'plist';
11
+
12
+ export type FailureMode = 'open' | 'closed';
13
+
14
+ const OPENCODE_PROCESS_TAG = '213584428666668';
15
+
16
+ /** Allowed base URL hosts. */
17
+ const ALLOWED_HOST = /\.(collector-)?crowdstrike\.com$/;
18
+
19
+ /**
20
+ * Plugin options from `opencode.json`.
21
+ */
22
+ const Options = Schema.Struct({
23
+ token: Schema.optional(Schema.String),
24
+ baseURL: Schema.optional(Schema.String),
25
+ /** Seconds to wait for a verdict, including polling. */
26
+ timeout: Schema.optional(Schema.Number),
27
+ failureMode: Schema.optional(Schema.Literals(['open', 'closed'])),
28
+ });
29
+
30
+ export interface Config {
31
+ /** Collector token, or why the collector can't use one. */
32
+ readonly token: string | Error;
33
+ readonly baseURL: string;
34
+ readonly timeoutMillis: number;
35
+ readonly failureMode: FailureMode;
36
+ }
37
+
38
+ /**
39
+ * The collector config from plugin `options` and `env`.
40
+ */
41
+ export function config(
42
+ options: unknown,
43
+ env: Readonly<Record<string, string | undefined>>
44
+ ): Config {
45
+ const decoded = Schema.decodeUnknownExit(Options)(options);
46
+ const opts = Exit.isSuccess(decoded) ? decoded.value : {};
47
+ const token = opts.token ?? env.CS_AIDR_TOKEN;
48
+ const baseURL =
49
+ opts.baseURL ??
50
+ env.CS_AIDR_BASE_URL ??
51
+ 'https://api.crowdstrike.com/aidr/aiguard';
52
+ const url = URL.parse(baseURL);
53
+ return {
54
+ token: Exit.isFailure(decoded)
55
+ ? new Error(`invalid plugin options: ${decoded.cause}`)
56
+ : url?.protocol !== 'https:' || !ALLOWED_HOST.test(url.hostname)
57
+ ? new Error(
58
+ `base URL ${baseURL} is not HTTPS on *.crowdstrike.com or *.collector-crowdstrike.com`
59
+ )
60
+ : (token ?? new Error('CS_AIDR_TOKEN is not set')),
61
+ baseURL,
62
+ timeoutMillis: (opts.timeout ?? 30) * 1000,
63
+ failureMode: opts.failureMode ?? 'closed',
64
+ };
65
+ }
66
+
67
+ const ToolCall = Schema.Struct({
68
+ id: Schema.String,
69
+ type: Schema.Literal('function'),
70
+ function: Schema.Struct({
71
+ name: Schema.String,
72
+ /** JSON-encoded arguments. */
73
+ arguments: Schema.String,
74
+ }),
75
+ });
76
+
77
+ /** An OpenAI Chat Completions message. */
78
+ const Message = Schema.Union([
79
+ Schema.Struct({ role: Schema.Literal('user'), content: Schema.String }),
80
+ Schema.Struct({
81
+ role: Schema.Literal('assistant'),
82
+ content: Schema.optional(Schema.NullOr(Schema.String)),
83
+ tool_calls: Schema.Array(ToolCall),
84
+ }),
85
+ Schema.Struct({
86
+ role: Schema.Literal('tool'),
87
+ tool_call_id: Schema.String,
88
+ content: Schema.String,
89
+ }),
90
+ ]);
91
+ type Message = typeof Message.Type;
92
+
93
+ /**
94
+ * `guard_input` as sent, and `guard_output` as returned with redactions
95
+ * applied.
96
+ */
97
+ const GuardInput = Schema.Struct({ messages: Schema.Array(Message) });
98
+
99
+ /**
100
+ * What the collector knows about the OpenCode process and session beyond the
101
+ * hook event.
102
+ */
103
+ export interface Meta {
104
+ readonly user: string | undefined;
105
+ /** Directory of the plugin's OpenCode location. */
106
+ readonly directory: string;
107
+ readonly version: string;
108
+ readonly processID: number;
109
+ /** Model of the session's latest model request, if any. */
110
+ readonly model: Model.Ref | undefined;
111
+ readonly sensorAID: string | undefined;
112
+ }
113
+
114
+ /**
115
+ * The Falcon sensor agent ID in `reg query <CSAgent\Sim> /v AG` output, as
116
+ * lowercase hex. `undefined` unless `AG` holds exactly 16 bytes.
117
+ */
118
+ export function windowsSensorAID(regOutput: string): string | undefined {
119
+ return /^\s*AG\s+REG_BINARY\s+([0-9A-F]{32})\s*$/im
120
+ .exec(regOutput)?.[1]
121
+ ?.toLowerCase();
122
+ }
123
+
124
+ /**
125
+ * The Falcon sensor agent ID in `falconctl info` output, as lowercase hex.
126
+ * `undefined` unless the plist's `aid` holds 32 hex digits.
127
+ */
128
+ export function macSensorAID(falconctlOutput: string): string | undefined {
129
+ let info: plist.PlistValue;
130
+ try {
131
+ info = plist.parse(falconctlOutput);
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ const aid =
136
+ typeof info === 'object' && info !== null && 'aid' in info
137
+ ? info.aid
138
+ : undefined;
139
+ return typeof aid === 'string' && /^[0-9A-F]{32}$/i.test(aid)
140
+ ? aid.toLowerCase()
141
+ : undefined;
142
+ }
143
+
144
+ export type PromptEvent = SessionPrompt;
145
+ export type ToolInputEvent = ToolHooks['execute.before'];
146
+ export type ToolOutputEvent = ToolHooks['execute.after'] & {
147
+ readonly status: 'completed';
148
+ };
149
+
150
+ export function promptRequest(
151
+ event: PromptEvent,
152
+ meta: Meta
153
+ ): ChatCompletionsGuard {
154
+ return request(
155
+ 'input',
156
+ 'session.prompt',
157
+ event.sessionID,
158
+ [{ role: 'user', content: event.prompt.text }],
159
+ meta
160
+ );
161
+ }
162
+
163
+ export function toolInputRequest(
164
+ event: ToolInputEvent,
165
+ meta: Meta
166
+ ): ChatCompletionsGuard {
167
+ return request(
168
+ 'tool_input',
169
+ 'tool.execute.before',
170
+ event.sessionID,
171
+ [toolCall(event)],
172
+ meta,
173
+ event
174
+ );
175
+ }
176
+
177
+ export function toolOutputRequest(
178
+ event: ToolOutputEvent,
179
+ meta: Meta
180
+ ): ChatCompletionsGuard {
181
+ const output = {
182
+ role: 'tool',
183
+ tool_call_id: event.id,
184
+ content: outputText(event.result),
185
+ } as const;
186
+ return request(
187
+ 'tool_output',
188
+ 'tool.execute.after',
189
+ event.sessionID,
190
+ [toolCall(event), output],
191
+ meta,
192
+ event
193
+ );
194
+ }
195
+
196
+ function request(
197
+ eventType: 'input' | 'tool_input' | 'tool_output',
198
+ hook: 'session.prompt' | 'tool.execute.before' | 'tool.execute.after',
199
+ sessionID: string,
200
+ messages: readonly Message[],
201
+ meta: Meta,
202
+ tool?: { readonly tool: string; readonly agent: string }
203
+ ): ChatCompletionsGuard {
204
+ return {
205
+ guard_input: { messages },
206
+ event_type: eventType,
207
+ llm_provider: meta.model?.providerID,
208
+ model: meta.model?.id,
209
+ session_id: sessionID,
210
+ user_id: meta.user,
211
+ agent_type: 'code-assistant',
212
+ is_agent: true,
213
+ process_id: String(meta.processID),
214
+ process_tags: [OPENCODE_PROCESS_TAG],
215
+ extra_info: {
216
+ app_name: 'OpenCode',
217
+ app_version: meta.version,
218
+ user_name: meta.user,
219
+ opencode_hook_name: hook,
220
+ opencode_directory: meta.directory,
221
+ opencode_tool_name: tool?.tool,
222
+ opencode_agent: tool?.agent,
223
+ sensor_aid: meta.sensorAID,
224
+ },
225
+ };
226
+ }
227
+
228
+ function toolCall(event: ToolInputEvent | ToolOutputEvent): Message {
229
+ return {
230
+ role: 'assistant',
231
+ content: null,
232
+ tool_calls: [
233
+ {
234
+ id: event.id,
235
+ type: 'function',
236
+ function: {
237
+ name: event.tool,
238
+ arguments: JSON.stringify(event.input) ?? 'null',
239
+ },
240
+ },
241
+ ],
242
+ };
243
+ }
244
+
245
+ function outputText({ content, output }: Tool.Result): string {
246
+ if (typeof content === 'string') return content;
247
+ if (content !== undefined && content.length > 0)
248
+ return content
249
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
250
+ .join('\n');
251
+ return typeof output === 'string' ? output : (JSON.stringify(output) ?? '');
252
+ }
253
+
254
+ export type Verdict =
255
+ | { readonly type: 'allow' }
256
+ | { readonly type: 'block'; readonly reason: string }
257
+ /** The redacted copy of the last message sent. */
258
+ | { readonly type: 'redact'; readonly message: Message };
259
+
260
+ type GuardResult = AidrPostV1GuardChatCompletionsResponses[200]['result'];
261
+
262
+ /**
263
+ * The verdict in a successful Falcon Guardian response. Throws if a redaction
264
+ * has no usable `guard_output`.
265
+ */
266
+ export function verdict(res: {
267
+ readonly summary?: string;
268
+ readonly result?: GuardResult;
269
+ }): Verdict {
270
+ if (res.result?.blocked)
271
+ return {
272
+ type: 'block',
273
+ reason: `Falcon Guardian: ${res.summary ?? 'blocked by policy'}`,
274
+ };
275
+ if (!res.result?.transformed) return { type: 'allow' };
276
+ const message = Schema.decodeUnknownSync(GuardInput)(
277
+ res.result.guard_output
278
+ ).messages.at(-1);
279
+ if (message === undefined) throw new Error('guard_output has no messages');
280
+ return { type: 'redact', message };
281
+ }
282
+
283
+ /** The verdict when Falcon Guardian can't give one because of `error`. */
284
+ export function failure(mode: FailureMode, error: unknown): Verdict {
285
+ return mode === 'open'
286
+ ? { type: 'allow' }
287
+ : { type: 'block', reason: `Falcon Guardian: ${error}` };
288
+ }
289
+
290
+ /**
291
+ * Rewrite the prompt for `verdict`. Returns the reason to reject the prompt
292
+ * with, if any.
293
+ */
294
+ export function applyToPrompt(
295
+ event: PromptEvent,
296
+ verdict: Verdict
297
+ ): string | undefined {
298
+ if (verdict.type === 'allow') return undefined;
299
+ if (verdict.type === 'block') return verdict.reason;
300
+ if (verdict.message.role !== 'user')
301
+ throw new Error("guard_output doesn't end with a user message");
302
+ const { files, agents, skills } = event.prompt;
303
+ // Mention offsets point into the original text.
304
+ event.prompt = {
305
+ text: verdict.message.content,
306
+ files: files?.map(({ mention: _, ...file }) => file),
307
+ agents: agents?.map(({ mention: _, ...agent }) => agent),
308
+ skills: skills?.map(({ mention: _, ...skill }) => skill),
309
+ };
310
+ return undefined;
311
+ }
312
+
313
+ /**
314
+ * Rewrite the tool input for `verdict`. Returns the reason to reject the call
315
+ * with, if any.
316
+ */
317
+ export function applyToToolInput(
318
+ event: ToolInputEvent,
319
+ verdict: Verdict
320
+ ): string | undefined {
321
+ if (verdict.type === 'allow') return undefined;
322
+ if (verdict.type === 'block') return verdict.reason;
323
+ const call =
324
+ verdict.message.role === 'assistant'
325
+ ? verdict.message.tool_calls[0]
326
+ : undefined;
327
+ if (call === undefined)
328
+ throw new Error("guard_output doesn't end with a tool call");
329
+ event.input = JSON.parse(call.function.arguments);
330
+ return undefined;
331
+ }
332
+
333
+ /**
334
+ * Rewrite the tool output for `verdict`. Returns the reason to reject the
335
+ * output with, if any.
336
+ */
337
+ export function applyToToolOutput(
338
+ event: ToolOutputEvent,
339
+ verdict: Verdict
340
+ ): string | undefined {
341
+ if (verdict.type === 'allow') return undefined;
342
+ if (verdict.type === 'block') return verdict.reason;
343
+ if (verdict.message.role !== 'tool')
344
+ throw new Error("guard_output doesn't end with a tool message");
345
+ event.result = { content: verdict.message.content };
346
+ return undefined;
347
+ }
package/src/index.ts ADDED
@@ -0,0 +1,155 @@
1
+ /** Falcon Guardian collector for OpenCode. */
2
+
3
+ import { AIGuard, type ChatCompletionsGuard } from '@crowdstrike/aidr';
4
+ import { type Model, Plugin } from '@opencode/plugin/effect';
5
+ import { Tool } from '@opencode/schema/tool';
6
+ import { Cause, Effect } from 'effect';
7
+ import { safeExeca } from 'safe-execa';
8
+ import * as guard from './guard.ts';
9
+
10
+ /** Keep the thrown error, so its message reaches the log and closed reasons. */
11
+ const unwrap = (error: unknown) => error;
12
+ const attempt = <A>(f: () => A) => Effect.try({ try: f, catch: unwrap });
13
+
14
+ const REG_KEY = 'HKLM\\System\\CurrentControlSet\\services\\CSAgent\\Sim';
15
+ const FALCONCTL = '/Applications/Falcon.app/Contents/Resources/falconctl';
16
+
17
+ /** `parse` of the command's stdout. `undefined` on any failure. */
18
+ const readCommand = (
19
+ file: string,
20
+ args: readonly string[],
21
+ parse: (stdout: string) => string | undefined
22
+ ): Effect.Effect<string | undefined> =>
23
+ Effect.tryPromise(() => safeExeca(file, args, { timeout: 5000 })).pipe(
24
+ Effect.map(({ stdout }) =>
25
+ typeof stdout === 'string' ? parse(stdout) : undefined
26
+ ),
27
+ Effect.orElseSucceed(() => undefined)
28
+ );
29
+
30
+ /**
31
+ * The Falcon sensor agent ID from the Windows registry or macOS `falconctl`.
32
+ * `undefined` on other platforms or on any failure.
33
+ */
34
+ const readSensorAID: Effect.Effect<string | undefined> =
35
+ process.platform === 'win32'
36
+ ? readCommand('reg', ['query', REG_KEY, '/v', 'AG'], guard.windowsSensorAID)
37
+ : process.platform === 'darwin'
38
+ ? readCommand(FALCONCTL, ['info'], guard.macSensorAID)
39
+ : Effect.succeed(undefined);
40
+
41
+ export default Plugin.define({
42
+ id: 'falcon-guardian',
43
+ effect: Effect.fn(function* (ctx) {
44
+ const config = guard.config(ctx.options, process.env);
45
+ if (config.token instanceof Error)
46
+ yield* Effect.logError(`Falcon Guardian: ${config.token.message}`);
47
+ const client =
48
+ typeof config.token === 'string'
49
+ ? new AIGuard({
50
+ token: config.token,
51
+ baseURLTemplate: config.baseURL,
52
+ timeout: config.timeoutMillis,
53
+ })
54
+ : undefined;
55
+ const user = process.env.USER ?? process.env.USERNAME;
56
+ const sensorAID = yield* readSensorAID;
57
+ const models = new Map<string, Model.Ref>();
58
+ const meta = (sessionID: string): guard.Meta => ({
59
+ user,
60
+ directory: ctx.location.directory,
61
+ version: ctx.app.version,
62
+ processID: process.pid,
63
+ model: models.get(sessionID),
64
+ sensorAID,
65
+ });
66
+
67
+ const ask = (request: ChatCompletionsGuard) =>
68
+ client === undefined
69
+ ? Effect.fail(config.token)
70
+ : Effect.tryPromise({
71
+ try: (signal) => client.guardChatCompletions(request, { signal }),
72
+ catch: unwrap,
73
+ }).pipe(
74
+ Effect.timeoutOrElse({
75
+ duration: config.timeoutMillis,
76
+ orElse: () =>
77
+ Effect.fail(
78
+ new Error(
79
+ `AIDR gave no verdict within ${config.timeoutMillis} ms`
80
+ )
81
+ ),
82
+ }),
83
+ Effect.flatMap((res) =>
84
+ res.status === 'Success'
85
+ ? attempt(() => guard.verdict(res))
86
+ : Effect.fail(
87
+ new Error(
88
+ `AIDR returned ${res.status}: ${res.summary ?? ''}`
89
+ )
90
+ )
91
+ )
92
+ );
93
+
94
+ /** Build the request, get the verdict, and apply it. */
95
+ const guarded = <A>(
96
+ build: () => ChatCompletionsGuard,
97
+ apply: (verdict: guard.Verdict) => A
98
+ ) =>
99
+ attempt(build).pipe(
100
+ Effect.flatMap(ask),
101
+ Effect.flatMap((verdict) => attempt(() => apply(verdict))),
102
+ Effect.catchCause((cause) => {
103
+ const error = Cause.squash(cause);
104
+ return Effect.logWarning('Falcon Guardian: no verdict', cause).pipe(
105
+ Effect.as(
106
+ apply(
107
+ guard.failure(
108
+ config.failureMode,
109
+ error instanceof Error ? error.message : error
110
+ )
111
+ )
112
+ )
113
+ );
114
+ })
115
+ );
116
+
117
+ // The prompt and execute.after hooks have no error channel, so a block is a
118
+ // defect. OpenCode then fails the prompt request or the turn.
119
+ const die = (reason: string | undefined) =>
120
+ reason === undefined ? Effect.void : Effect.die(new Error(reason));
121
+
122
+ yield* ctx.session.hook('context', (event) =>
123
+ Effect.sync(() => void models.set(event.sessionID, event.model))
124
+ );
125
+
126
+ yield* ctx.session.hook('prompt', (event) =>
127
+ guarded(
128
+ () => guard.promptRequest(event, meta(event.sessionID)),
129
+ (verdict) => guard.applyToPrompt(event, verdict)
130
+ ).pipe(Effect.flatMap(die))
131
+ );
132
+
133
+ yield* ctx.tool.hook('execute.before', (event) =>
134
+ guarded(
135
+ () => guard.toolInputRequest(event, meta(event.sessionID)),
136
+ (verdict) => guard.applyToToolInput(event, verdict)
137
+ ).pipe(
138
+ Effect.flatMap((reason) =>
139
+ reason === undefined
140
+ ? Effect.void
141
+ : Effect.fail(new Tool.Error({ message: reason }))
142
+ )
143
+ )
144
+ );
145
+
146
+ yield* ctx.tool.hook('execute.after', (event) =>
147
+ event.status === 'completed'
148
+ ? guarded(
149
+ () => guard.toolOutputRequest(event, meta(event.sessionID)),
150
+ (verdict) => guard.applyToToolOutput(event, verdict)
151
+ ).pipe(Effect.flatMap(die))
152
+ : Effect.void
153
+ );
154
+ }),
155
+ });