agent-relay-server 0.123.1 → 0.123.2
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/README.md +3 -1
- package/docs/openapi.json +1 -1
- package/package.json +3 -2
- package/runner/hooks/rg-r-guard/hooks.json +18 -0
- package/runner/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/services/spawn-chain-inspector.ts +134 -0
package/README.md
CHANGED
|
@@ -477,7 +477,9 @@ client/ # TypeScript client (npm: agent-relay-clien
|
|
|
477
477
|
## Development
|
|
478
478
|
|
|
479
479
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and
|
|
480
|
-
[SECURITY.md](SECURITY.md) for vulnerability reporting.
|
|
480
|
+
[SECURITY.md](SECURITY.md) for vulnerability reporting. Before a feature counts as
|
|
481
|
+
shipped, work it through the [Feature Delivery Guide](docs/feature-delivery.md) — the
|
|
482
|
+
end-to-end Definition of Done that stops assembly-correct-but-dead features.
|
|
481
483
|
|
|
482
484
|
```bash
|
|
483
485
|
git clone https://github.com/edimuj/agent-relay.git
|
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.123.
|
|
5
|
+
"version": "0.123.2",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.123.
|
|
3
|
+
"version": "0.123.2",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"scripts/orchestrator-spawn-smoke.ts",
|
|
29
29
|
"runner/src/**/*.ts",
|
|
30
30
|
"runner/plugins/**",
|
|
31
|
+
"runner/hooks/**",
|
|
31
32
|
"!runner/**/*.test.ts",
|
|
32
33
|
"public/**",
|
|
33
34
|
"docs/openapi.json",
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
"CONTRIBUTING.md"
|
|
37
38
|
],
|
|
38
39
|
"dependencies": {
|
|
39
|
-
"agent-relay-channels-host": "0.123.
|
|
40
|
+
"agent-relay-channels-host": "0.123.2",
|
|
40
41
|
"agent-relay-providers": "0.104.4",
|
|
41
42
|
"agent-relay-sdk": "0.2.115",
|
|
42
43
|
"ajv": "^8.20.0"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Bash",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node \"${HOOK_DIR}/rg-r-guard.cjs\"",
|
|
10
|
+
"timeout": 5,
|
|
11
|
+
"statusMessage": "Checking ripgrep replace guard",
|
|
12
|
+
"suppressOutput": true
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
let input = "";
|
|
5
|
+
process.stdin.setEncoding("utf8");
|
|
6
|
+
process.stdin.on("data", (chunk) => {
|
|
7
|
+
input += chunk;
|
|
8
|
+
});
|
|
9
|
+
process.stdin.on("end", () => {
|
|
10
|
+
let payload;
|
|
11
|
+
try {
|
|
12
|
+
payload = JSON.parse(input || "{}");
|
|
13
|
+
} catch {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const toolName = String(payload.tool_name || payload.toolName || "").toLowerCase();
|
|
18
|
+
const command = String(payload.tool_input?.command || payload.toolInput?.command || "");
|
|
19
|
+
if (toolName !== "bash" || !command || !containsRipgrepReplaceFootgun(command)) return;
|
|
20
|
+
|
|
21
|
+
const reason = "Blocked: `rg -r` is ripgrep replace, not recursive search. Use `rg PATTERN PATH` for search, or `rg --replace REPLACEMENT PATTERN` only when replacement output is intentional.";
|
|
22
|
+
process.stdout.write(JSON.stringify({
|
|
23
|
+
hookSpecificOutput: {
|
|
24
|
+
hookEventName: "PreToolUse",
|
|
25
|
+
permissionDecision: "deny",
|
|
26
|
+
permissionDecisionReason: reason,
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
process.stdout.write("\n");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function containsRipgrepReplaceFootgun(command) {
|
|
33
|
+
for (const segment of splitShellSegments(command)) {
|
|
34
|
+
const words = shellWords(segment);
|
|
35
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
36
|
+
const word = basename(words[i] || "");
|
|
37
|
+
if (word !== "rg" && word !== "ripgrep") continue;
|
|
38
|
+
if (ripgrepArgsUseReplace(words.slice(i + 1))) return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ripgrepArgsUseReplace(args) {
|
|
45
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
46
|
+
const arg = args[i] || "";
|
|
47
|
+
if (arg === "--") return false;
|
|
48
|
+
if (arg === "--replace" || arg.startsWith("--replace=")) return true;
|
|
49
|
+
if (arg === "-r") return true;
|
|
50
|
+
if (/^-([A-Za-z]*r[A-Za-z]*|[A-Za-z]*r=.+)$/.test(arg)) return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function splitShellSegments(text) {
|
|
56
|
+
const segments = [];
|
|
57
|
+
let current = "";
|
|
58
|
+
let quote = "";
|
|
59
|
+
let escaped = false;
|
|
60
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
61
|
+
const ch = text[i];
|
|
62
|
+
if (escaped) {
|
|
63
|
+
current += ch;
|
|
64
|
+
escaped = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (ch === "\\") {
|
|
68
|
+
current += ch;
|
|
69
|
+
escaped = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (quote) {
|
|
73
|
+
current += ch;
|
|
74
|
+
if (ch === quote) quote = "";
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ch === "'" || ch === "\"") {
|
|
78
|
+
quote = ch;
|
|
79
|
+
current += ch;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Command-substitution + grouping boundaries (`$(rg -r …)`, backtick `` `rg -r …` ``,
|
|
83
|
+
// subshells) split into their own segments so the inner command is scanned on its own
|
|
84
|
+
// (basename of `$(rg` ≠ rg otherwise) and its args never bleed into the outer command's
|
|
85
|
+
// scan. Only fires OUTSIDE quotes — a single/double-quoted literal is left intact, matching
|
|
86
|
+
// the tokenizer's existing quote handling. Known gap: substitution inside double quotes
|
|
87
|
+
// ("$(rg -r x)") is still treated as an opaque quoted word.
|
|
88
|
+
if (ch === "(" || ch === ")" || ch === "`") {
|
|
89
|
+
if (current.trim()) segments.push(current);
|
|
90
|
+
current = "";
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
|
|
94
|
+
if (current.trim()) segments.push(current);
|
|
95
|
+
current = "";
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
current += ch;
|
|
99
|
+
}
|
|
100
|
+
if (current.trim()) segments.push(current);
|
|
101
|
+
return segments;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function shellWords(text) {
|
|
105
|
+
const words = [];
|
|
106
|
+
let current = "";
|
|
107
|
+
let quote = "";
|
|
108
|
+
let escaped = false;
|
|
109
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
110
|
+
const ch = text[i];
|
|
111
|
+
if (escaped) {
|
|
112
|
+
current += ch;
|
|
113
|
+
escaped = false;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === "\\") {
|
|
117
|
+
escaped = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (quote) {
|
|
121
|
+
if (ch === quote) quote = "";
|
|
122
|
+
else current += ch;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ch === "'" || ch === "\"") {
|
|
126
|
+
quote = ch;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (/\s/.test(ch)) {
|
|
130
|
+
if (current) {
|
|
131
|
+
words.push(current);
|
|
132
|
+
current = "";
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
current += ch;
|
|
137
|
+
}
|
|
138
|
+
if (current) words.push(current);
|
|
139
|
+
return words;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function basename(value) {
|
|
143
|
+
return value.split("/").pop() || value;
|
|
144
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Real-chain spawn inspector (#1103 L1 seed) — the regression harness whose absence let two
|
|
2
|
+
// dead #1096 fixes ship. It drives the ACTUAL spawn chain for a named profile, end to end, with
|
|
3
|
+
// NO hand-fed config objects:
|
|
4
|
+
//
|
|
5
|
+
// getAgentProfile(name) — the real built-in/stored profile
|
|
6
|
+
// → withResolvedProvisioning — relay-side registry resolution (needs a seeded DB)
|
|
7
|
+
// → buildSpawnCommand — the command params the orchestrator dispatches
|
|
8
|
+
// → JSON round-trip — the command-bus / AGENT_RELAY_AGENT_PROFILE_JSON env seam
|
|
9
|
+
// → spawnOptionsFromControl — orchestrator re-parse of the command
|
|
10
|
+
// → ClaudeAdapter.buildSpawnArgs — runner assembly + hook/skill/plugin MATERIALIZATION to disk
|
|
11
|
+
//
|
|
12
|
+
// then reads back the EFFECTIVE on-disk artifacts: the written managed `settings.json`, the
|
|
13
|
+
// materialized `relay-provisioned-hooks/` tree, and the assembled argv. Two prior fixes each
|
|
14
|
+
// tested one half in isolation (relay resolution OR runner materialization) with a hand-fed
|
|
15
|
+
// `provisioning.hooks`, so neither caught that the bundled hook asset is never SHIPPED (the
|
|
16
|
+
// `runner/hooks/**` packaging gap) and so never seeds → resolves empty on a real deployed spawn.
|
|
17
|
+
// This helper runs the whole chain against the real DB + real package layout, so that class of
|
|
18
|
+
// break surfaces as a red on-disk artifact instead of a green half-test.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import type { SpawnProvider } from "agent-relay-sdk";
|
|
23
|
+
import { withResolvedProvisioning } from "../db";
|
|
24
|
+
import { getAgentProfile } from "../config-store";
|
|
25
|
+
import { buildSpawnCommand } from "../spawn-command";
|
|
26
|
+
import { spawnOptionsFromControl } from "../../orchestrator/src/control";
|
|
27
|
+
import { ClaudeAdapter } from "../../runner/src/adapters/claude";
|
|
28
|
+
import { defaultProviderConfig } from "../../runner/src/config";
|
|
29
|
+
import type { RunnerSpawnConfig } from "../../runner/src/adapter";
|
|
30
|
+
|
|
31
|
+
/** The bundled baseline safety hook whose silent absence is the #1096 bug. */
|
|
32
|
+
export const BASELINE_HOOK_REF = "agent-relay-rg-r-guard";
|
|
33
|
+
export const BASELINE_HOOK_SCRIPT = "rg-r-guard.cjs";
|
|
34
|
+
|
|
35
|
+
export interface SpawnChainInspection {
|
|
36
|
+
profile: string;
|
|
37
|
+
provider: SpawnProvider;
|
|
38
|
+
/** The isolated managed CLAUDE_CONFIG_DIR the runner assembled + materialized into. */
|
|
39
|
+
configHome: string | undefined;
|
|
40
|
+
/** Absolute path of the managed settings.json (whether or not it exists). */
|
|
41
|
+
settingsPath: string | undefined;
|
|
42
|
+
/** Parsed on-disk settings.json (null when relay wrote none). */
|
|
43
|
+
settings: Record<string, unknown> | null;
|
|
44
|
+
/** The effective launch argv the adapter would exec. */
|
|
45
|
+
argv: string[];
|
|
46
|
+
/** The launch command (claude / claude-rig). */
|
|
47
|
+
command: string;
|
|
48
|
+
/** relay-provisioned-hooks/<ref> dir for the baseline guard. */
|
|
49
|
+
baselineHookAssetDir: string | undefined;
|
|
50
|
+
baselineHookAssetExists: boolean;
|
|
51
|
+
baselineHookScriptExists: boolean;
|
|
52
|
+
/** True iff the WRITTEN settings.json (the documented, transit-safe hook source) carries the
|
|
53
|
+
* baseline PreToolUse guard — the #1096 proof-of-life bar. */
|
|
54
|
+
settingsHasBaselineHook: boolean;
|
|
55
|
+
/** True iff SOME launch channel (written settings.json OR inline --settings) carries it. */
|
|
56
|
+
launchHasBaselineHook: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function settingsCarriesBaselineHook(settings: Record<string, unknown> | null): boolean {
|
|
60
|
+
const pre = (settings?.hooks as { PreToolUse?: unknown } | undefined)?.PreToolUse;
|
|
61
|
+
if (!Array.isArray(pre)) return false;
|
|
62
|
+
return JSON.stringify(pre).includes(BASELINE_HOOK_SCRIPT);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function argvCarriesBaselineHook(argv: string[]): boolean {
|
|
66
|
+
const idx = argv.findIndex((a) => a === "--settings");
|
|
67
|
+
if (idx < 0 || idx + 1 >= argv.length) return false;
|
|
68
|
+
return argv[idx + 1]!.includes(BASELINE_HOOK_SCRIPT);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run the real spawn chain for a profile and read back the on-disk artifacts. Two preconditions,
|
|
73
|
+
* both owned by the caller so this stays a pure harness (no global/env mutation of its own):
|
|
74
|
+
* • the DB is seeded (initDb) so registry resolution can find the bundled hook, and
|
|
75
|
+
* • AGENT_RELAY_PROVIDER_HOME_ROOT points at a scratch dir (the runner's config module reads it
|
|
76
|
+
* to place isolated provider homes) — set it like launch-assembly.test.ts does.
|
|
77
|
+
*/
|
|
78
|
+
export function inspectSpawnChain(opts: {
|
|
79
|
+
profile: string;
|
|
80
|
+
provider: SpawnProvider;
|
|
81
|
+
cwd: string;
|
|
82
|
+
approvalMode?: string;
|
|
83
|
+
}): SpawnChainInspection {
|
|
84
|
+
const { profile, provider, cwd } = opts;
|
|
85
|
+
|
|
86
|
+
// 1. RELAY: resolve the named profile's provisioning against the registry.
|
|
87
|
+
const resolved = withResolvedProvisioning(getAgentProfile(profile)?.value, provider);
|
|
88
|
+
if (!resolved) throw new Error(`profile not found: ${profile}`);
|
|
89
|
+
|
|
90
|
+
// 2. RELAY: build the spawn command params the orchestrator dispatches.
|
|
91
|
+
const cmdParams = buildSpawnCommand({
|
|
92
|
+
provider, cwd, agentProfile: resolved, env: {}, requestedBy: "spawn-chain-inspector",
|
|
93
|
+
} as Parameters<typeof buildSpawnCommand>[0]);
|
|
94
|
+
|
|
95
|
+
// 3. SEAM: JSON round-trip through the command bus / AGENT_RELAY_AGENT_PROFILE_JSON env var.
|
|
96
|
+
const roundTripped = JSON.parse(JSON.stringify(cmdParams)) as Record<string, unknown>;
|
|
97
|
+
|
|
98
|
+
// 4. ORCHESTRATOR: re-parse the command into spawn options.
|
|
99
|
+
const spawnOpts = spawnOptionsFromControl(roundTripped, { providers: [provider], baseDir: cwd } as never);
|
|
100
|
+
|
|
101
|
+
// 5. RUNNER: assemble the RunnerSpawnConfig the adapter consumes.
|
|
102
|
+
const providerConfig = defaultProviderConfig(provider);
|
|
103
|
+
const config = {
|
|
104
|
+
provider, runnerId: "inspector", instanceId: `insp-${profile}`, agentId: "insp",
|
|
105
|
+
relayUrl: "http://localhost:4850", cwd, headless: true,
|
|
106
|
+
approvalMode: opts.approvalMode ?? "open", providerArgs: [], providerConfig,
|
|
107
|
+
env: {}, controlPort: 4900,
|
|
108
|
+
agentProfile: spawnOpts.agentProfile,
|
|
109
|
+
} as unknown as RunnerSpawnConfig;
|
|
110
|
+
|
|
111
|
+
// 6. RUNNER: the REAL adapter path — prepares the home, materializes provisioned assets to
|
|
112
|
+
// disk, and returns the launch argv (exactly what the runner execs).
|
|
113
|
+
const spawnArgs = new ClaudeAdapter().buildSpawnArgs(config, providerConfig);
|
|
114
|
+
|
|
115
|
+
// 7. Read back the effective on-disk artifacts.
|
|
116
|
+
const configHome = spawnArgs.env.CLAUDE_CONFIG_DIR;
|
|
117
|
+
const settingsPath = configHome ? join(configHome, "settings.json") : undefined;
|
|
118
|
+
const settings = settingsPath && existsSync(settingsPath)
|
|
119
|
+
? (JSON.parse(readFileSync(settingsPath, "utf8")) as Record<string, unknown>)
|
|
120
|
+
: null;
|
|
121
|
+
const baselineHookAssetDir = configHome
|
|
122
|
+
? join(configHome, "relay-provisioned-hooks", BASELINE_HOOK_REF)
|
|
123
|
+
: undefined;
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
profile, provider, configHome, settingsPath, settings,
|
|
127
|
+
argv: spawnArgs.args, command: spawnArgs.command,
|
|
128
|
+
baselineHookAssetDir,
|
|
129
|
+
baselineHookAssetExists: Boolean(baselineHookAssetDir && existsSync(baselineHookAssetDir)),
|
|
130
|
+
baselineHookScriptExists: Boolean(baselineHookAssetDir && existsSync(join(baselineHookAssetDir, BASELINE_HOOK_SCRIPT))),
|
|
131
|
+
settingsHasBaselineHook: settingsCarriesBaselineHook(settings),
|
|
132
|
+
launchHasBaselineHook: settingsCarriesBaselineHook(settings) || argvCarriesBaselineHook(spawnArgs.args),
|
|
133
|
+
};
|
|
134
|
+
}
|