@stroq/cli 0.1.0 → 0.10.1
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/DISCLOSURE +68 -0
- package/README.md +37 -14
- package/dist/corpus.json +415 -0
- package/dist/index.js +4352 -314
- package/openclaw-plugin/README.md +49 -0
- package/openclaw-plugin/index.js +146 -0
- package/openclaw-plugin/openclaw.plugin.json +27 -0
- package/openclaw-plugin/package.json +17 -0
- package/openclaw-plugin/run-stroq.js +166 -0
- package/package.json +7 -2
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Stroq plugin for OpenClaw
|
|
2
|
+
|
|
3
|
+
A thin gate that forwards every tool call to the Stroq CLI and does what it says.
|
|
4
|
+
It is shipped inside `@stroq/cli`; `stroq init --agent openclaw` copies it to
|
|
5
|
+
`$STROQ_HOME/openclaw-plugin/` (default `~/.stroq/openclaw-plugin/`), writes a
|
|
6
|
+
`stroq.json` recording how to start Stroq, and then runs — or prints — these two:
|
|
7
|
+
|
|
8
|
+
openclaw plugins install --link ~/.stroq/openclaw-plugin
|
|
9
|
+
openclaw plugins enable stroq
|
|
10
|
+
|
|
11
|
+
Restart the Gateway afterwards: plugins are loaded when it starts.
|
|
12
|
+
|
|
13
|
+
## Hooks
|
|
14
|
+
|
|
15
|
+
- `before_tool_call` (priority 100, no matcher) runs `stroq hook openclaw pre`.
|
|
16
|
+
`allow` returns nothing, `deny` blocks with the rule and reason, `ask` raises a
|
|
17
|
+
`requireApproval` prompt answered with `/approve <id> allow-once|deny`.
|
|
18
|
+
- `after_tool_call` runs `stroq hook openclaw post`, which scans the result, records
|
|
19
|
+
provenance and taints the session. It is observe-only: the warning is logged, the
|
|
20
|
+
taint is enforced on the next tool call.
|
|
21
|
+
|
|
22
|
+
**Fail-closed.** Every one of these blocks the call, which is OpenClaw's own policy
|
|
23
|
+
for this hook: a missing binary, a spawn error, a non-zero exit, a timeout, an aborted
|
|
24
|
+
run, a reply larger than 1 MiB (a hung CLI, not a decision), params that cannot be
|
|
25
|
+
serialised, stdout that is not JSON or is JSON but not an object, and a decision this
|
|
26
|
+
plugin does not know. `after_tool_call` never throws — the tool has already run — and
|
|
27
|
+
a scan that fails there is logged at `warn`, since a failed scan means no taint.
|
|
28
|
+
|
|
29
|
+
If the `stroq.json` `init` recorded points at an entry that no longer exists (an npx
|
|
30
|
+
cache that has since been pruned), the plugin logs one warning and falls back to
|
|
31
|
+
`stroq` on PATH rather than blocking every call on the same ENOENT. When there is no
|
|
32
|
+
Stroq on PATH either, the call still fails closed.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
`plugins.entries.stroq.config` in `openclaw.json`. It is read **once**, when the
|
|
37
|
+
plugin registers, so restart the Gateway after changing any of it:
|
|
38
|
+
|
|
39
|
+
| Key | Default | What it does |
|
|
40
|
+
| -------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| `stroqBin` | `stroq.json`, else `stroq` on PATH | Path to the Stroq binary to spawn, as one string (never split on spaces, and never existence-checked — a binary you named must fail loudly rather than be silently replaced). `STROQ_BIN` is read before `stroq.json`, and a `stroq.json` whose recorded entry file is gone is skipped in favour of PATH. A launch command needing extra arguments belongs in `stroq.json`'s `command` array instead. |
|
|
42
|
+
| `workspace` | the Gateway's `process.cwd()` | The project directory for the secret index and path rules. A tool call's own `params.cwd` is never used for this, for any tool including `exec`: honouring one would let an agent point it at an empty directory and slip past a secret-egress guard. |
|
|
43
|
+
| `timeoutMs` | `10000` | How long Stroq gets to answer before the call is blocked. |
|
|
44
|
+
| `askTimeoutMs` | `120000` | How long an approval prompt stays open, clamped to OpenClaw's documented 1 000–600 000 ms range. |
|
|
45
|
+
| `logLevel` | the Gateway's own | Declared for the plugin manager's UI; this entry logs at fixed levels (`warn` for a block, a suspect result, a failed post scan or a stale `stroq.json`, `info` for an approval resolution) and lets the Gateway's logger filter them. |
|
|
46
|
+
|
|
47
|
+
Set `plugins.entries.stroq.enabled` to `true`, and add `stroq` to `plugins.allow` if
|
|
48
|
+
an allowlist is configured. This directory has no dependencies and is plain ESM
|
|
49
|
+
JavaScript, so nothing is installed or built when it is linked.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Stroq plugin for OpenClaw: turns `before_tool_call` / `after_tool_call` into
|
|
2
|
+
// `stroq hook openclaw pre|post` child-process calls (spawning is in run-stroq.js).
|
|
3
|
+
// Fail-closed by construction — every one of these blocks the call: a missing binary,
|
|
4
|
+
// a spawn error, a non-zero exit, a timeout, an aborted run, a reply larger than
|
|
5
|
+
// 1 MiB, params that cannot be serialised, stdout that is not JSON or is JSON but not
|
|
6
|
+
// an object, and a decision this file does not know.
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { clip, runStroq, text } from './run-stroq.js';
|
|
9
|
+
|
|
10
|
+
const DESCRIPTION =
|
|
11
|
+
'Local action firewall for OpenClaw: scans what the agent reads, taints the session, blocks or asks before dangerous tool calls.';
|
|
12
|
+
const DEFAULT_ASK_TIMEOUT_MS = 120000;
|
|
13
|
+
// OpenClaw's own documented bounds for an approval prompt. A `timeoutMs` past the
|
|
14
|
+
// maximum is not a longer prompt, it is a `requireApproval` the Gateway may reject
|
|
15
|
+
// outright — and a rejected approval is a call nobody was ever asked about.
|
|
16
|
+
const MIN_ASK_TIMEOUT_MS = 1000;
|
|
17
|
+
const MAX_ASK_TIMEOUT_MS = 600000;
|
|
18
|
+
const MAX_TITLE = 80;
|
|
19
|
+
const MAX_DESCRIPTION = 512;
|
|
20
|
+
const load = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
/** `definePluginEntry`, from whichever SDK path this Gateway build exposes. */
|
|
23
|
+
function resolveDefinePluginEntry() {
|
|
24
|
+
for (const id of ['openclaw/plugin-sdk/plugin-entry', 'openclaw/plugin-sdk/core']) {
|
|
25
|
+
try {
|
|
26
|
+
const mod = load(id);
|
|
27
|
+
const fn = mod?.definePluginEntry ?? mod?.default?.definePluginEntry;
|
|
28
|
+
if (typeof fn === 'function') return fn;
|
|
29
|
+
} catch {
|
|
30
|
+
// not exposed by this build; try the next id, then fall back to bare `register`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Logging never decides anything: an absent logger is skipped and a throwing one is swallowed. */
|
|
37
|
+
function logAt(api, level, message) {
|
|
38
|
+
const fn = api && api.logger && api.logger[level];
|
|
39
|
+
try {
|
|
40
|
+
if (typeof fn === 'function') fn.call(api.logger, message);
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Both phases' payload. `cwd` is always the plugin's OWN directory — `config.workspace`,
|
|
46
|
+
* else `process.cwd()` — and never a tool call's `params.cwd`: honouring a
|
|
47
|
+
* model-supplied `cwd` here would let any tool point the project directory (and so
|
|
48
|
+
* the secret index) at an empty one and walk straight past a secret-egress guard.
|
|
49
|
+
* `exec` is no exception: the CLI adapter also never reads `params.cwd` for this
|
|
50
|
+
* (Task 4.5 review, Critical — it used to, and that was the actual bypass). `params`
|
|
51
|
+
* is still forwarded whole regardless, `cwd` included, purely for the audit trail.
|
|
52
|
+
*/
|
|
53
|
+
function payloadFor(phase, event, ctx, config) {
|
|
54
|
+
const params = event.params && typeof event.params === 'object' ? event.params : {};
|
|
55
|
+
const c = ctx || {};
|
|
56
|
+
const base = {
|
|
57
|
+
sessionId: text(c.sessionKey) || text(c.sessionId) || 'openclaw',
|
|
58
|
+
agentId: c.agentId,
|
|
59
|
+
runId: c.runId ?? event.runId,
|
|
60
|
+
toolCallId: c.toolCallId ?? event.toolCallId,
|
|
61
|
+
toolKind: event.toolKind ?? c.toolKind,
|
|
62
|
+
requester: c.requester,
|
|
63
|
+
toolName: text(event.toolName),
|
|
64
|
+
params,
|
|
65
|
+
cwd: text(config.workspace) || process.cwd(),
|
|
66
|
+
};
|
|
67
|
+
if (phase === 'pre') return base;
|
|
68
|
+
return { ...base, result: event.result, error: event.error, durationMs: event.durationMs };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** How long the prompt stays open, clamped to the range OpenClaw documents. */
|
|
72
|
+
function askTimeout(config) {
|
|
73
|
+
const configured = Number(config.askTimeoutMs);
|
|
74
|
+
if (!(configured > 0)) return DEFAULT_ASK_TIMEOUT_MS;
|
|
75
|
+
return Math.min(Math.max(configured, MIN_ASK_TIMEOUT_MS), MAX_ASK_TIMEOUT_MS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `ask` as OpenClaw's approval request, inside its documented 80/512/600 000 caps. */
|
|
79
|
+
function approval(api, event, reply, config) {
|
|
80
|
+
const ms = askTimeout(config);
|
|
81
|
+
return {
|
|
82
|
+
title: clip(`Stroq: ${text(reply.ruleId) || 'policy'}`, MAX_TITLE),
|
|
83
|
+
description: clip(text(reply.reason) || 'Stroq asks before this action.', MAX_DESCRIPTION),
|
|
84
|
+
severity: 'warning',
|
|
85
|
+
// `allow-always` is deliberately not offered: Stroq audits every ask, and a
|
|
86
|
+
// remembered allow is one it would never be asked about again.
|
|
87
|
+
allowedDecisions: ['allow-once', 'deny'],
|
|
88
|
+
timeoutMs: ms,
|
|
89
|
+
onResolution: (decision) =>
|
|
90
|
+
logAt(api, 'info', `stroq: approval ${decision} for ${text(event.toolName)}`),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function register(api) {
|
|
95
|
+
const config = (api && api.pluginConfig) || {};
|
|
96
|
+
// `detail` is clipped like a child's stderr: it is a block reason shown to a user.
|
|
97
|
+
const block = (event, detail) => {
|
|
98
|
+
const clipped = clip(String(detail), 300);
|
|
99
|
+
logAt(api, 'warn', `stroq: ${text(event && event.toolName) || 'tool'}: ${clipped}`);
|
|
100
|
+
return { block: true, blockReason: `Stroq internal error (fail-closed): ${clipped}` };
|
|
101
|
+
};
|
|
102
|
+
// A stale `stroq.json` entry is reported at `warn`: the operator should repair it.
|
|
103
|
+
const warn = (message) => logAt(api, 'warn', `stroq: ${message}`);
|
|
104
|
+
// Priority 100 so Stroq answers before ordinary hooks; no matcher, every tool goes through.
|
|
105
|
+
api.on(
|
|
106
|
+
'before_tool_call',
|
|
107
|
+
async (event, ctx) => {
|
|
108
|
+
let outcome;
|
|
109
|
+
try {
|
|
110
|
+
const payload = payloadFor('pre', event, ctx, config);
|
|
111
|
+
outcome = await runStroq(config, 'pre', payload, ctx?.abortSignal, warn);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return block(event, `cannot read the tool call: ${String(err)}`);
|
|
114
|
+
}
|
|
115
|
+
if (outcome.error) return block(event, outcome.error);
|
|
116
|
+
const reply = outcome.reply;
|
|
117
|
+
if (reply.decision === 'allow') return undefined;
|
|
118
|
+
if (reply.decision === 'ask') return { requireApproval: approval(api, event, reply, config) };
|
|
119
|
+
if (reply.decision === 'deny')
|
|
120
|
+
return {
|
|
121
|
+
block: true,
|
|
122
|
+
blockReason: `Stroq blocked this action (${text(reply.ruleId) || 'policy'}): ${text(reply.reason) || 'no reason given'}`,
|
|
123
|
+
};
|
|
124
|
+
return block(event, `unknown decision ${JSON.stringify(reply.decision)}`);
|
|
125
|
+
},
|
|
126
|
+
{ priority: 100 },
|
|
127
|
+
);
|
|
128
|
+
// Observe-only and never throws; the taint it sets is enforced on the NEXT call. The
|
|
129
|
+
// abort signal is not forwarded: a cancelled run's result must still be scanned.
|
|
130
|
+
api.on('after_tool_call', async (event, ctx) => {
|
|
131
|
+
try {
|
|
132
|
+
const payload = payloadFor('post', event, ctx, config);
|
|
133
|
+
const outcome = await runStroq(config, 'post', payload, undefined, warn);
|
|
134
|
+
// `warn`: a scan nobody looked at leaves the session untainted; operators must see it.
|
|
135
|
+
if (outcome.error) logAt(api, 'warn', `stroq: post scan failed: ${outcome.error}`);
|
|
136
|
+
else if (text(outcome.reply.warning)) logAt(api, 'warn', `stroq: ${outcome.reply.warning}`);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
logAt(api, 'warn', `stroq: post scan failed: ${String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const definePluginEntry = resolveDefinePluginEntry();
|
|
144
|
+
export default definePluginEntry
|
|
145
|
+
? definePluginEntry({ id: 'stroq', name: 'Stroq', description: DESCRIPTION, register })
|
|
146
|
+
: register;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "stroq",
|
|
3
|
+
"name": "Stroq",
|
|
4
|
+
"description": "Local action firewall for OpenClaw: scans what the agent reads, taints the session, blocks or asks before dangerous tool calls.",
|
|
5
|
+
"version": "0.10.1",
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"stroqBin": { "type": "string" },
|
|
11
|
+
"workspace": { "type": "string" },
|
|
12
|
+
"timeoutMs": { "type": "integer", "minimum": 1000 },
|
|
13
|
+
"askTimeoutMs": { "type": "integer", "minimum": 1000 },
|
|
14
|
+
"logLevel": { "type": "string", "enum": ["warn", "info", "debug"] }
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"uiHints": {
|
|
18
|
+
"stroqBin": {
|
|
19
|
+
"label": "stroq command",
|
|
20
|
+
"help": "Path to the stroq binary. Defaults to `stroq` on PATH, then the path recorded by `stroq init --agent openclaw`."
|
|
21
|
+
},
|
|
22
|
+
"workspace": {
|
|
23
|
+
"label": "Project directory",
|
|
24
|
+
"help": "Used for the secret index and path classification. A tool call's own cwd is never used for this — see the plugin README."
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stroq-openclaw-plugin",
|
|
3
|
+
"version": "0.10.1",
|
|
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.1
|
|
3
|
+
"version": "0.10.1",
|
|
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,9 +9,14 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
+
"openclaw-plugin",
|
|
12
13
|
"README.md",
|
|
13
|
-
"LICENSE"
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"DISCLOSURE"
|
|
14
16
|
],
|
|
17
|
+
"contentPolicy": {
|
|
18
|
+
"class": "dual-use"
|
|
19
|
+
},
|
|
15
20
|
"engines": {
|
|
16
21
|
"node": ">=22"
|
|
17
22
|
},
|