@stroq/cli 0.5.1 → 0.11.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.
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "stroq-openclaw-plugin",
3
+ "version": "0.11.0",
4
+ "private": true,
5
+ "description": "Stroq's OpenClaw plugin entry: forwards before_tool_call and after_tool_call to the Stroq CLI.",
6
+ "type": "module",
7
+ "openclaw": {
8
+ "extensions": [
9
+ {
10
+ "entry": "index.js"
11
+ }
12
+ ]
13
+ },
14
+ "engines": {
15
+ "node": ">=22"
16
+ }
17
+ }
@@ -0,0 +1,166 @@
1
+ // Spawns the Stroq CLI for one hook phase and resolves to `{ reply }` or `{ error }`,
2
+ // never rejecting: every failure this plugin can see — a missing binary, a spawn
3
+ // error, a non-zero exit, a timeout, an aborted run, an oversized reply, or stdout
4
+ // that is not JSON — becomes an `{ error }` for index.js to turn into a block.
5
+ import { spawn } from 'node:child_process';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { dirname, isAbsolute, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const HERE = dirname(fileURLToPath(import.meta.url));
11
+ const DEFAULT_TIMEOUT_MS = 10000;
12
+ // A reply this large is not a decision Stroq ever sends; it is a hung or misbehaving
13
+ // CLI, and buffering it further would only delay the same block.
14
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
15
+ /** The launch command `stroq init --agent openclaw` records beside this file. */
16
+ const COMMAND_FILE = 'stroq.json';
17
+ /** The last resort: a bare name `spawn` resolves through PATH. */
18
+ const PATH_FALLBACK = 'stroq';
19
+
20
+ export const text = (value) => (typeof value === 'string' && value !== '' ? value : '');
21
+ export const clip = (value, max) => (value.length <= max ? value : `${value.slice(0, max - 3)}...`);
22
+
23
+ /** A launch command is a non-empty array of strings; anything else is not one. */
24
+ const isArgv = (value) =>
25
+ Array.isArray(value) && value.length > 0 && value.every((a) => typeof a === 'string');
26
+
27
+ /**
28
+ * The file a recorded command would actually run: the LAST absolute path in the argv,
29
+ * which is the entry rather than the interpreter — `['<node>', '--import', 'tsx',
30
+ * '/opt/stroq/src/index.ts']` names three paths and only the last one goes stale. A
31
+ * relative element is never treated as the entry: it resolves against a working
32
+ * directory this module does not know, so its absence would prove nothing.
33
+ */
34
+ export const recordedEntry = (argv) => {
35
+ for (let i = argv.length - 1; i >= 0; i -= 1) if (isAbsolute(argv[i])) return argv[i];
36
+ return null;
37
+ };
38
+
39
+ /**
40
+ * argv of the Stroq CLI, and the recorded entry (if any) that turned out to be gone:
41
+ * this plugin's config, then `STROQ_BIN`, then the `stroq.json` `stroq init --agent
42
+ * openclaw` wrote beside this file, then `stroq` on PATH.
43
+ *
44
+ * The recorded command is SKIPPED when its entry file no longer exists. `npx
45
+ * @stroq/cli init --agent openclaw` records a path inside the npx cache, and pruning
46
+ * that cache used to leave every single tool call blocked on an ENOENT nobody could
47
+ * read — a firewall bricking the agent because its own installer's temp directory was
48
+ * cleaned up. Falling back to PATH turns that into one warning plus a working Stroq
49
+ * wherever one is installed; when there is none, the call still fails closed.
50
+ *
51
+ * `stroqBin`/`STROQ_BIN` is always ONE path, never split on whitespace (a real install
52
+ * path can legitimately contain a space, and a launch command needing extra arguments
53
+ * belongs in `stroq.json`'s array-shaped `command` instead) and never existence-
54
+ * checked: an operator who named a binary must see it fail rather than be silently
55
+ * redirected to some other Stroq that happens to be on PATH.
56
+ *
57
+ * Pure — the config, the environment, the recorded command and the existence check
58
+ * are all parameters — so the whole order is testable without a filesystem.
59
+ */
60
+ export function resolveStroqArgv({ config = {}, env = {}, recorded = null, exists }) {
61
+ const configured = text(config.stroqBin) || text(env.STROQ_BIN);
62
+ if (configured) return { argv: [configured], staleEntry: null };
63
+ if (!isArgv(recorded)) return { argv: [PATH_FALLBACK], staleEntry: null };
64
+ const entry = recordedEntry(recorded);
65
+ if (entry !== null && !exists(entry)) return { argv: [PATH_FALLBACK], staleEntry: entry };
66
+ return { argv: [...recorded], staleEntry: null };
67
+ }
68
+
69
+ /** The `command` array `init` recorded, or `null` when there is none to read. */
70
+ function readRecordedCommand(file) {
71
+ try {
72
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')).command : null;
73
+ } catch {
74
+ // unreadable or not JSON: fall through to PATH
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** A stale entry is one fact about this install, not one per tool call. */
80
+ let warnedStale = false;
81
+
82
+ function stroqArgv(config, warn) {
83
+ const { argv, staleEntry } = resolveStroqArgv({
84
+ config,
85
+ env: process.env,
86
+ recorded: readRecordedCommand(join(HERE, COMMAND_FILE)),
87
+ exists: existsSync,
88
+ });
89
+ if (staleEntry !== null && !warnedStale) {
90
+ warnedStale = true;
91
+ warn(
92
+ `${COMMAND_FILE} records ${staleEntry}, which no longer exists; falling back to ` +
93
+ `"${PATH_FALLBACK}" on PATH. Install @stroq/cli globally and re-run ` +
94
+ '"stroq init --agent openclaw".',
95
+ );
96
+ }
97
+ return argv;
98
+ }
99
+
100
+ /** The child's answer: a reply object, or the reason it is not one. */
101
+ function replyOf(code, stdout, stderr) {
102
+ if (code !== 0)
103
+ return { error: `exit ${code}: ${clip(stderr.trim() || 'no reason given', 300)}` };
104
+ try {
105
+ const reply = JSON.parse(stdout);
106
+ if (reply && typeof reply === 'object') return { reply };
107
+ } catch {
108
+ // not an answer at all
109
+ }
110
+ return { error: `unreadable answer: ${clip(stdout.trim(), 200)}` };
111
+ }
112
+
113
+ /**
114
+ * Runs one phase and resolves to `{ reply }` or `{ error }`. Never rejects. `warn`
115
+ * carries the one message this module can produce that is not a decision — a
116
+ * `stroq.json` whose entry is gone — out to the Gateway's own logger.
117
+ */
118
+ export function runStroq(config, phase, payload, abortSignal, warn = () => {}) {
119
+ return new Promise((resolve) => {
120
+ let argv;
121
+ let stdin;
122
+ try {
123
+ argv = stroqArgv(config, warn);
124
+ stdin = JSON.stringify(payload);
125
+ } catch (err) {
126
+ resolve({ error: `cannot build the hook call: ${String(err)}` });
127
+ return;
128
+ }
129
+ const [bin, ...rest] = argv;
130
+ const child = spawn(bin, [...rest, 'hook', 'openclaw', phase], { signal: abortSignal });
131
+ let stdout = '';
132
+ let stderr = '';
133
+ let settled = false;
134
+ const finish = (value) => {
135
+ if (settled) return;
136
+ settled = true;
137
+ clearTimeout(timer);
138
+ resolve(value);
139
+ };
140
+ const ms = Number(config.timeoutMs) > 0 ? Number(config.timeoutMs) : DEFAULT_TIMEOUT_MS;
141
+ const timer = setTimeout(() => {
142
+ child.kill('SIGKILL');
143
+ finish({ error: `no answer in ${ms} ms` });
144
+ }, ms);
145
+ // Bytes, not UTF-16 code units: a cap called BYTES that counted `String.length`
146
+ // would let a reply of multi-byte characters buffer several times its own limit.
147
+ const checkSize = () => {
148
+ if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) <= MAX_OUTPUT_BYTES) return;
149
+ child.kill('SIGKILL');
150
+ finish({ error: `the reply exceeded ${MAX_OUTPUT_BYTES} bytes and was cut off` });
151
+ };
152
+ child.stdout.setEncoding('utf8');
153
+ child.stdout.on('data', (chunk) => {
154
+ stdout += chunk;
155
+ checkSize();
156
+ });
157
+ child.stderr.on('data', (chunk) => {
158
+ stderr += String(chunk);
159
+ checkSize();
160
+ });
161
+ child.stdin.on('error', () => {});
162
+ child.on('error', (err) => finish({ error: `cannot run ${bin}: ${err.message}` }));
163
+ child.on('close', (code) => finish(replyOf(code, stdout, stderr)));
164
+ child.stdin.end(stdin);
165
+ });
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stroq/cli",
3
- "version": "0.5.1",
3
+ "version": "0.11.0",
4
4
  "description": "Local action firewall for AI agents: scans what the agent reads, taints the session, blocks dangerous follow-up actions",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
+ "openclaw-plugin",
12
13
  "README.md",
13
14
  "LICENSE",
14
15
  "DISCLOSURE"
@@ -40,15 +41,15 @@
40
41
  "publishConfig": {
41
42
  "access": "public"
42
43
  },
44
+ "scripts": {
45
+ "build": "tsup",
46
+ "typecheck": "tsc --noEmit -p tsconfig.json"
47
+ },
43
48
  "dependencies": {
44
49
  "yaml": "2.9.0",
45
50
  "zod": "4.5.4"
46
51
  },
47
52
  "devDependencies": {
48
- "@stroq/core": "0.5.1"
49
- },
50
- "scripts": {
51
- "build": "tsup",
52
- "typecheck": "tsc --noEmit -p tsconfig.json"
53
+ "@stroq/core": "workspace:*"
53
54
  }
54
- }
55
+ }