@clawdreyhepburn/carapace 0.4.1 → 0.4.3
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/openclaw.plugin.json +56 -16
- package/package.json +1 -1
- package/src/cedar-engine-cedarling.ts +5 -64
- package/src/gui/html.ts +14 -0
- package/src/gui/server.ts +7 -18
- package/src/index.ts +221 -27
- package/src/llm-proxy.ts +0 -21
- package/src/types.ts +4 -1
package/openclaw.plugin.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"id": "carapace",
|
|
3
3
|
"name": "Carapace",
|
|
4
4
|
"description": "Immutable policy boundaries for MCP tool access. Your agent's exoskeleton.",
|
|
5
|
-
"version": "0.3
|
|
5
|
+
"version": "0.4.3",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
|
-
"additionalProperties":
|
|
8
|
+
"additionalProperties": true,
|
|
9
9
|
"properties": {
|
|
10
10
|
"guiPort": {
|
|
11
11
|
"type": "number",
|
|
@@ -20,13 +20,31 @@
|
|
|
20
20
|
"properties": {
|
|
21
21
|
"transport": {
|
|
22
22
|
"type": "string",
|
|
23
|
-
"enum": [
|
|
23
|
+
"enum": [
|
|
24
|
+
"stdio",
|
|
25
|
+
"http",
|
|
26
|
+
"sse"
|
|
27
|
+
],
|
|
24
28
|
"default": "stdio"
|
|
25
29
|
},
|
|
26
|
-
"command": {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
+
"command": {
|
|
31
|
+
"type": "string"
|
|
32
|
+
},
|
|
33
|
+
"args": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {
|
|
36
|
+
"type": "string"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"env": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": {
|
|
42
|
+
"type": "string"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"url": {
|
|
46
|
+
"type": "string"
|
|
47
|
+
}
|
|
30
48
|
}
|
|
31
49
|
}
|
|
32
50
|
},
|
|
@@ -37,7 +55,10 @@
|
|
|
37
55
|
},
|
|
38
56
|
"defaultPolicy": {
|
|
39
57
|
"type": "string",
|
|
40
|
-
"enum": [
|
|
58
|
+
"enum": [
|
|
59
|
+
"deny-all",
|
|
60
|
+
"allow-all"
|
|
61
|
+
],
|
|
41
62
|
"default": "allow-all",
|
|
42
63
|
"description": "Default policy for tools. allow-all (default) keeps everything working — use the GUI to restrict. deny-all requires explicit permits."
|
|
43
64
|
},
|
|
@@ -74,13 +95,32 @@
|
|
|
74
95
|
}
|
|
75
96
|
},
|
|
76
97
|
"uiHints": {
|
|
77
|
-
"guiPort": {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
98
|
+
"guiPort": {
|
|
99
|
+
"label": "GUI Port",
|
|
100
|
+
"placeholder": "19820"
|
|
101
|
+
},
|
|
102
|
+
"policyDir": {
|
|
103
|
+
"label": "Policy Directory"
|
|
104
|
+
},
|
|
105
|
+
"defaultPolicy": {
|
|
106
|
+
"label": "Default Policy for New Tools"
|
|
107
|
+
},
|
|
108
|
+
"verify": {
|
|
109
|
+
"label": "Enable Formal Verification"
|
|
110
|
+
},
|
|
111
|
+
"proxy.enabled": {
|
|
112
|
+
"label": "Enable LLM Proxy"
|
|
113
|
+
},
|
|
114
|
+
"proxy.port": {
|
|
115
|
+
"label": "Proxy Port",
|
|
116
|
+
"placeholder": "19821"
|
|
117
|
+
},
|
|
118
|
+
"proxy.upstream": {
|
|
119
|
+
"label": "Upstream API URL"
|
|
120
|
+
},
|
|
121
|
+
"proxy.apiKey": {
|
|
122
|
+
"label": "Upstream API Key",
|
|
123
|
+
"sensitive": true
|
|
124
|
+
}
|
|
85
125
|
}
|
|
86
126
|
}
|
package/package.json
CHANGED
|
@@ -150,18 +150,14 @@ export class CedarlingEngine {
|
|
|
150
150
|
const typeMatch = request.resource.match(/^(?:\w+::)?(\w+)::/);
|
|
151
151
|
if (typeMatch) resourceEntityType = typeMatch[1];
|
|
152
152
|
|
|
153
|
-
const cedarContext: Record<string, unknown> = { ...(request.context ?? {}) };
|
|
154
|
-
|
|
155
|
-
const effectivePrincipalId = principalId;
|
|
156
|
-
|
|
157
153
|
const result = await this.cedarling.authorize_unsigned({
|
|
158
154
|
principals: [
|
|
159
155
|
{
|
|
160
156
|
cedar_entity_mapping: {
|
|
161
157
|
entity_type: `${this.namespace}::${this.agentEntityType}`,
|
|
162
|
-
id:
|
|
158
|
+
id: principalId,
|
|
163
159
|
},
|
|
164
|
-
name:
|
|
160
|
+
name: principalId,
|
|
165
161
|
},
|
|
166
162
|
],
|
|
167
163
|
action: `${this.namespace}::Action::"${actionName}"`,
|
|
@@ -172,7 +168,7 @@ export class CedarlingEngine {
|
|
|
172
168
|
},
|
|
173
169
|
...(request.context ?? {}),
|
|
174
170
|
},
|
|
175
|
-
context:
|
|
171
|
+
context: request.context ?? {},
|
|
176
172
|
});
|
|
177
173
|
|
|
178
174
|
const decision = result.decision ? "allow" : "deny";
|
|
@@ -500,38 +496,6 @@ export class CedarlingEngine {
|
|
|
500
496
|
},
|
|
501
497
|
},
|
|
502
498
|
},
|
|
503
|
-
Agent: {
|
|
504
|
-
shape: {
|
|
505
|
-
type: "Record",
|
|
506
|
-
attributes: {
|
|
507
|
-
role: {
|
|
508
|
-
type: "EntityOrCommon",
|
|
509
|
-
name: "String",
|
|
510
|
-
required: false,
|
|
511
|
-
},
|
|
512
|
-
parentChain: {
|
|
513
|
-
type: "Set",
|
|
514
|
-
element: { type: "EntityOrCommon", name: "String" },
|
|
515
|
-
required: false,
|
|
516
|
-
},
|
|
517
|
-
issuer: {
|
|
518
|
-
type: "EntityOrCommon",
|
|
519
|
-
name: "String",
|
|
520
|
-
required: false,
|
|
521
|
-
},
|
|
522
|
-
depth: {
|
|
523
|
-
type: "EntityOrCommon",
|
|
524
|
-
name: "Long",
|
|
525
|
-
required: false,
|
|
526
|
-
},
|
|
527
|
-
attestation_proven: {
|
|
528
|
-
type: "EntityOrCommon",
|
|
529
|
-
name: "Boolean",
|
|
530
|
-
required: false,
|
|
531
|
-
},
|
|
532
|
-
},
|
|
533
|
-
},
|
|
534
|
-
},
|
|
535
499
|
Tool: {
|
|
536
500
|
shape: {
|
|
537
501
|
type: "Record",
|
|
@@ -546,21 +510,6 @@ export class CedarlingEngine {
|
|
|
546
510
|
name: "String",
|
|
547
511
|
required: false,
|
|
548
512
|
},
|
|
549
|
-
project: {
|
|
550
|
-
type: "EntityOrCommon",
|
|
551
|
-
name: "String",
|
|
552
|
-
required: false,
|
|
553
|
-
},
|
|
554
|
-
team: {
|
|
555
|
-
type: "EntityOrCommon",
|
|
556
|
-
name: "String",
|
|
557
|
-
required: false,
|
|
558
|
-
},
|
|
559
|
-
domain: {
|
|
560
|
-
type: "EntityOrCommon",
|
|
561
|
-
name: "String",
|
|
562
|
-
required: false,
|
|
563
|
-
},
|
|
564
513
|
},
|
|
565
514
|
},
|
|
566
515
|
},
|
|
@@ -607,17 +556,9 @@ export class CedarlingEngine {
|
|
|
607
556
|
actions: {
|
|
608
557
|
call_tool: {
|
|
609
558
|
appliesTo: {
|
|
610
|
-
principalTypes: [this.agentEntityType
|
|
559
|
+
principalTypes: [this.agentEntityType],
|
|
611
560
|
resourceTypes: ["Tool"],
|
|
612
|
-
context: {
|
|
613
|
-
type: "Record",
|
|
614
|
-
attributes: {
|
|
615
|
-
agent_role: { type: "EntityOrCommon", name: "String", required: false },
|
|
616
|
-
agent_issuer: { type: "EntityOrCommon", name: "String", required: false },
|
|
617
|
-
agent_depth: { type: "EntityOrCommon", name: "Long", required: false },
|
|
618
|
-
agent_attestation_proven: { type: "EntityOrCommon", name: "Boolean", required: false },
|
|
619
|
-
},
|
|
620
|
-
},
|
|
561
|
+
context: { type: "Record", attributes: {} },
|
|
621
562
|
},
|
|
622
563
|
},
|
|
623
564
|
list_tools: {
|
package/src/gui/html.ts
CHANGED
|
@@ -339,6 +339,13 @@ export function guiHtml(): string {
|
|
|
339
339
|
</div>
|
|
340
340
|
</header>
|
|
341
341
|
|
|
342
|
+
<div id="not-enforcing-banner" style="display:none; background:#e8a735; color:#1a1a1a; padding:1rem 1.5rem; font-size:0.95rem; text-align:center; font-weight:500; line-height:1.6;">
|
|
343
|
+
⚠️ Carapace is loaded but <strong>not enforcing</strong>. No tools are gated and the LLM proxy is disabled.
|
|
344
|
+
Your agent is running without policy protection.<br>
|
|
345
|
+
To activate, run these two commands in your terminal:<br>
|
|
346
|
+
<code style="background:rgba(0,0,0,0.1); padding:0.3em 0.6em; border-radius:3px; display:inline-block; margin-top:0.3rem;">openclaw carapace setup && openclaw gateway restart</code>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
342
349
|
<div class="container">
|
|
343
350
|
<div id="servers-section">
|
|
344
351
|
<h2>MCP Servers</h2>
|
|
@@ -533,6 +540,13 @@ export function guiHtml(): string {
|
|
|
533
540
|
function render() {
|
|
534
541
|
document.getElementById('total-count').textContent = state.toolCount ?? state.tools.length;
|
|
535
542
|
document.getElementById('enabled-count').textContent = state.enabledCount ?? state.tools.filter(t => t.enabled).length;
|
|
543
|
+
|
|
544
|
+
// Show/hide not-enforcing banner
|
|
545
|
+
const banner = document.getElementById('not-enforcing-banner');
|
|
546
|
+
if (banner) {
|
|
547
|
+
banner.style.display = state.notEnforcing ? 'block' : 'none';
|
|
548
|
+
}
|
|
549
|
+
|
|
536
550
|
renderServers();
|
|
537
551
|
renderTools();
|
|
538
552
|
renderPolicies();
|
package/src/gui/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ interface GuiOpts {
|
|
|
15
15
|
aggregator: McpAggregator;
|
|
16
16
|
cedar: CedarEngineInterface;
|
|
17
17
|
logger: Logger;
|
|
18
|
+
proxyEnabled?: boolean;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export class ControlGui {
|
|
@@ -23,14 +24,14 @@ export class ControlGui {
|
|
|
23
24
|
private cedar: CedarEngineInterface;
|
|
24
25
|
private logger: Logger;
|
|
25
26
|
private server: Server | null = null;
|
|
26
|
-
|
|
27
|
+
private proxyEnabled: boolean;
|
|
27
28
|
|
|
28
29
|
constructor(opts: GuiOpts) {
|
|
29
30
|
this.port = opts.port;
|
|
30
31
|
this.aggregator = opts.aggregator;
|
|
31
32
|
this.cedar = opts.cedar;
|
|
32
33
|
this.logger = opts.logger;
|
|
33
|
-
|
|
34
|
+
this.proxyEnabled = opts.proxyEnabled ?? false;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
async start(): Promise<void> {
|
|
@@ -62,13 +63,16 @@ export class ControlGui {
|
|
|
62
63
|
if (url.pathname === "/api/status" && req.method === "GET") {
|
|
63
64
|
const servers = this.aggregator.getServerStatus();
|
|
64
65
|
const tools = this.aggregator.listTools();
|
|
66
|
+
const enabledCount = tools.filter((t) => t.enabled).length;
|
|
65
67
|
this.json(res, {
|
|
66
68
|
servers,
|
|
67
69
|
tools,
|
|
68
70
|
policies: this.cedar.getPolicies(),
|
|
69
71
|
toolCount: tools.length,
|
|
70
|
-
enabledCount
|
|
72
|
+
enabledCount,
|
|
71
73
|
defaultPolicy: this.cedar.getDefaultPolicy?.() ?? "allow-all",
|
|
74
|
+
proxyEnabled: this.proxyEnabled,
|
|
75
|
+
notEnforcing: !this.proxyEnabled && enabledCount === 0,
|
|
72
76
|
});
|
|
73
77
|
return;
|
|
74
78
|
}
|
|
@@ -136,21 +140,6 @@ export class ControlGui {
|
|
|
136
140
|
return;
|
|
137
141
|
}
|
|
138
142
|
|
|
139
|
-
if (url.pathname === "/api/agents" && req.method === "GET") {
|
|
140
|
-
// Agent hierarchy removed — see @clawdreyhepburn/ovid-me for per-agent mandates
|
|
141
|
-
this.json(res, []);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (url.pathname === "/api/policy-source" && req.method === "GET") {
|
|
146
|
-
// Return all Cedar policies (deployment-wide ceiling)
|
|
147
|
-
const policies = this.cedar.getPolicies();
|
|
148
|
-
const policyText = policies.map(p => p.raw).join("\n\n");
|
|
149
|
-
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
150
|
-
res.end(policyText || "# No policies defined\n");
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
143
|
// --- GUI ---
|
|
155
144
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
156
145
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
package/src/index.ts
CHANGED
|
@@ -11,8 +11,6 @@ import { ControlGui } from "./gui/server.js";
|
|
|
11
11
|
import { LlmProxy } from "./llm-proxy.js";
|
|
12
12
|
import type { PluginConfig } from "./types.js";
|
|
13
13
|
|
|
14
|
-
export { CarapacePolicySource } from "./policy-source.js";
|
|
15
|
-
export type { PolicySource } from "./policy-source.js";
|
|
16
14
|
export const id = "carapace";
|
|
17
15
|
export const name = "Carapace";
|
|
18
16
|
|
|
@@ -43,6 +41,46 @@ interface OpenClawPluginApi {
|
|
|
43
41
|
registerGatewayMethod?(name: string, handler: (ctx: { respond: (ok: boolean, data: any) => void }) => void): void;
|
|
44
42
|
}
|
|
45
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Build upstream config from either string or object format.
|
|
46
|
+
* String format: proxy.upstream = "https://api.anthropic.com", proxy.apiKey = "sk-..."
|
|
47
|
+
* Object format: proxy.upstream = { anthropic: { url, apiKey }, openai: { url, apiKey } }
|
|
48
|
+
*/
|
|
49
|
+
function buildUpstreamConfig(proxyConfig: NonNullable<PluginConfig["proxy"]>): {
|
|
50
|
+
anthropic?: { url: string; apiKey: string };
|
|
51
|
+
openai?: { url: string; apiKey: string };
|
|
52
|
+
} {
|
|
53
|
+
const upstream = proxyConfig.upstream;
|
|
54
|
+
|
|
55
|
+
if (!upstream) return {};
|
|
56
|
+
|
|
57
|
+
// String format: single upstream URL + flat apiKey
|
|
58
|
+
if (typeof upstream === "string") {
|
|
59
|
+
const apiKey = proxyConfig.apiKey ?? "";
|
|
60
|
+
const url = upstream;
|
|
61
|
+
// Guess provider from URL
|
|
62
|
+
if (url.includes("anthropic")) {
|
|
63
|
+
return { anthropic: { url, apiKey } };
|
|
64
|
+
} else if (url.includes("openai")) {
|
|
65
|
+
return { openai: { url, apiKey } };
|
|
66
|
+
}
|
|
67
|
+
// Default to anthropic
|
|
68
|
+
return { anthropic: { url, apiKey } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Object format: multi-provider
|
|
72
|
+
return {
|
|
73
|
+
anthropic: upstream.anthropic ? {
|
|
74
|
+
url: upstream.anthropic.url ?? "https://api.anthropic.com",
|
|
75
|
+
apiKey: upstream.anthropic.apiKey,
|
|
76
|
+
} : undefined,
|
|
77
|
+
openai: upstream.openai ? {
|
|
78
|
+
url: upstream.openai.url ?? "https://api.openai.com",
|
|
79
|
+
apiKey: upstream.openai.apiKey,
|
|
80
|
+
} : undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
46
84
|
export default function register(api: OpenClawPluginApi) {
|
|
47
85
|
const config: PluginConfig = api.pluginConfig ?? {};
|
|
48
86
|
const logger = api.logger;
|
|
@@ -60,27 +98,20 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
60
98
|
logger,
|
|
61
99
|
});
|
|
62
100
|
|
|
101
|
+
// --- LLM Proxy: intercept tool calls at the API level ---
|
|
102
|
+
const proxyConfig = config.proxy;
|
|
103
|
+
|
|
63
104
|
const gui = new ControlGui({
|
|
64
105
|
port: config.guiPort ?? 19820,
|
|
65
106
|
aggregator,
|
|
66
107
|
cedar,
|
|
67
108
|
logger,
|
|
109
|
+
proxyEnabled: !!proxyConfig?.enabled,
|
|
68
110
|
});
|
|
69
111
|
|
|
70
|
-
|
|
71
|
-
const proxyConfig = config.proxy;
|
|
72
|
-
const proxy = proxyConfig?.enabled ? new LlmProxy({
|
|
112
|
+
let proxy: LlmProxy | null = proxyConfig?.enabled ? new LlmProxy({
|
|
73
113
|
port: proxyConfig.port ?? 19821,
|
|
74
|
-
upstream:
|
|
75
|
-
anthropic: proxyConfig.upstream?.anthropic ? {
|
|
76
|
-
url: proxyConfig.upstream.anthropic.url ?? "https://api.anthropic.com",
|
|
77
|
-
apiKey: proxyConfig.upstream.anthropic.apiKey,
|
|
78
|
-
} : undefined,
|
|
79
|
-
openai: proxyConfig.upstream?.openai ? {
|
|
80
|
-
url: proxyConfig.upstream.openai.url ?? "https://api.openai.com",
|
|
81
|
-
apiKey: proxyConfig.upstream.openai.apiKey,
|
|
82
|
-
} : undefined,
|
|
83
|
-
},
|
|
114
|
+
upstream: buildUpstreamConfig(proxyConfig),
|
|
84
115
|
cedar,
|
|
85
116
|
logger,
|
|
86
117
|
}) : null;
|
|
@@ -132,6 +163,17 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
132
163
|
return { patched: toAdd, alreadyDenied };
|
|
133
164
|
}
|
|
134
165
|
|
|
166
|
+
function backupConfig(): void {
|
|
167
|
+
const { readFileSync, writeFileSync, existsSync, copyFileSync } = require("node:fs");
|
|
168
|
+
const { join } = require("node:path");
|
|
169
|
+
const { homedir } = require("node:os");
|
|
170
|
+
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
|
171
|
+
if (existsSync(configPath)) {
|
|
172
|
+
const backupPath = configPath + ".carapace-backup";
|
|
173
|
+
copyFileSync(configPath, backupPath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
135
177
|
function patchConfigProxyBaseUrl(): { patched: string[]; alreadySet: string[] } {
|
|
136
178
|
const { readFileSync, writeFileSync, existsSync } = require("node:fs");
|
|
137
179
|
const { join } = require("node:path");
|
|
@@ -144,31 +186,83 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
144
186
|
const port = config.proxy?.port ?? 19821;
|
|
145
187
|
const proxyUrl = `http://127.0.0.1:${port}`;
|
|
146
188
|
|
|
147
|
-
// Figure out which providers
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
189
|
+
// Figure out which providers are configured
|
|
190
|
+
const upstreamConfig = proxyConfig ? buildUpstreamConfig(proxyConfig) : {};
|
|
191
|
+
const providers = Object.keys(upstreamConfig).filter(
|
|
192
|
+
(k) => upstreamConfig[k as keyof typeof upstreamConfig],
|
|
193
|
+
);
|
|
151
194
|
|
|
152
195
|
const patched: string[] = [];
|
|
153
196
|
const alreadySet: string[] = [];
|
|
154
197
|
|
|
155
198
|
if (!cfg.models) cfg.models = {};
|
|
199
|
+
if (!cfg.models.mode) cfg.models.mode = "merge";
|
|
156
200
|
if (!cfg.models.providers) cfg.models.providers = {};
|
|
157
201
|
|
|
158
202
|
for (const provider of providers) {
|
|
159
203
|
if (!cfg.models.providers[provider]) cfg.models.providers[provider] = {};
|
|
204
|
+
// Ensure models array exists (OpenClaw requires it)
|
|
205
|
+
if (!Array.isArray(cfg.models.providers[provider].models)) {
|
|
206
|
+
cfg.models.providers[provider].models = [];
|
|
207
|
+
}
|
|
160
208
|
if (cfg.models.providers[provider].baseUrl === proxyUrl) {
|
|
161
209
|
alreadySet.push(provider);
|
|
162
210
|
} else {
|
|
211
|
+
// Store original baseUrl for clean revert
|
|
212
|
+
if (cfg.models.providers[provider].baseUrl && cfg.models.providers[provider].baseUrl !== proxyUrl) {
|
|
213
|
+
cfg.models.providers[provider]._originalBaseUrl = cfg.models.providers[provider].baseUrl;
|
|
214
|
+
}
|
|
163
215
|
cfg.models.providers[provider].baseUrl = proxyUrl;
|
|
164
216
|
patched.push(provider);
|
|
165
217
|
}
|
|
166
218
|
}
|
|
167
219
|
|
|
168
|
-
|
|
220
|
+
// Ensure plugin config is under plugins.entries.carapace.config
|
|
221
|
+
if (!cfg.plugins) cfg.plugins = {};
|
|
222
|
+
if (!cfg.plugins.entries) cfg.plugins.entries = {};
|
|
223
|
+
if (!cfg.plugins.entries.carapace) cfg.plugins.entries.carapace = {};
|
|
224
|
+
if (!cfg.plugins.entries.carapace.config) cfg.plugins.entries.carapace.config = {};
|
|
225
|
+
|
|
226
|
+
if (patched.length > 0 || !cfg.plugins.entries.carapace.enabled) {
|
|
227
|
+
cfg.plugins.entries.carapace.enabled = true;
|
|
169
228
|
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
170
229
|
}
|
|
171
230
|
|
|
231
|
+
// Also patch the per-agent models.json (this is what OpenClaw actually reads at runtime)
|
|
232
|
+
// openclaw.json models.providers gets merged INTO models.json on restart,
|
|
233
|
+
// but if someone restores openclaw.json from backup, models.json keeps the stale baseUrl.
|
|
234
|
+
// So we patch both files for safety.
|
|
235
|
+
const agentModelsPath = join(homedir(), ".openclaw", "agents", "main", "agent", "models.json");
|
|
236
|
+
if (existsSync(agentModelsPath)) {
|
|
237
|
+
try {
|
|
238
|
+
const agentModels = JSON.parse(readFileSync(agentModelsPath, "utf-8"));
|
|
239
|
+
let agentModelsChanged = false;
|
|
240
|
+
if (!agentModels.providers) agentModels.providers = {};
|
|
241
|
+
for (const provider of providers) {
|
|
242
|
+
if (!agentModels.providers[provider]) agentModels.providers[provider] = {};
|
|
243
|
+
if (agentModels.providers[provider].baseUrl !== proxyUrl) {
|
|
244
|
+
if (agentModels.providers[provider].baseUrl && agentModels.providers[provider].baseUrl !== proxyUrl) {
|
|
245
|
+
agentModels.providers[provider]._originalBaseUrl = agentModels.providers[provider].baseUrl;
|
|
246
|
+
}
|
|
247
|
+
agentModels.providers[provider].baseUrl = proxyUrl;
|
|
248
|
+
agentModelsChanged = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (agentModelsChanged) {
|
|
252
|
+
// Backup models.json before modifying
|
|
253
|
+
const modelsBackup = agentModelsPath + ".carapace-backup";
|
|
254
|
+
if (!existsSync(modelsBackup)) {
|
|
255
|
+
const { copyFileSync } = require("node:fs");
|
|
256
|
+
copyFileSync(agentModelsPath, modelsBackup);
|
|
257
|
+
}
|
|
258
|
+
writeFileSync(agentModelsPath, JSON.stringify(agentModels, null, 2) + "\n", "utf-8");
|
|
259
|
+
patched.push(...providers.map(p => `models.json:${p}`));
|
|
260
|
+
}
|
|
261
|
+
} catch (e: any) {
|
|
262
|
+
logger.warn(`Failed to patch agent models.json: ${e.message}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
172
266
|
return { patched, alreadySet };
|
|
173
267
|
}
|
|
174
268
|
|
|
@@ -184,10 +278,27 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
184
278
|
|
|
185
279
|
if (proxy) {
|
|
186
280
|
await proxy.start();
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
281
|
+
|
|
282
|
+
// Health check: verify proxy is actually responding
|
|
283
|
+
const proxyPort = proxyConfig!.port ?? 19821;
|
|
284
|
+
try {
|
|
285
|
+
const controller = new AbortController();
|
|
286
|
+
const timer = setTimeout(() => controller.abort(), 3000);
|
|
287
|
+
const healthResp = await fetch(`http://127.0.0.1:${proxyPort}/health`, { signal: controller.signal });
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
if (!healthResp.ok) throw new Error(`HTTP ${healthResp.status}`);
|
|
290
|
+
} catch (err: any) {
|
|
291
|
+
logger.error(`❌ Proxy health check failed on port ${proxyPort}: ${err.message}. Disabling proxy.`);
|
|
292
|
+
try { await proxy.stop(); } catch {}
|
|
293
|
+
proxy = null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (proxy) {
|
|
297
|
+
logger.info(
|
|
298
|
+
`🛡️ LLM Proxy active on http://127.0.0.1:${proxyPort} — ` +
|
|
299
|
+
`all tool calls go through Cedar`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
191
302
|
} else {
|
|
192
303
|
// Check for bypass vulnerabilities only when proxy is disabled
|
|
193
304
|
const bypasses = checkForBypasses();
|
|
@@ -199,6 +310,17 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
199
310
|
);
|
|
200
311
|
}
|
|
201
312
|
}
|
|
313
|
+
|
|
314
|
+
// Warn if Carapace is loaded but not actually enforcing anything
|
|
315
|
+
const tools = aggregator.listTools();
|
|
316
|
+
const enabledCount = tools.filter((t: any) => t.enabled).length;
|
|
317
|
+
if (!proxy && enabledCount === 0) {
|
|
318
|
+
logger.warn(
|
|
319
|
+
`⚠️ Carapace is loaded but NOT ENFORCING. No tools are gated and the LLM proxy is disabled. ` +
|
|
320
|
+
`Your agent is running without policy protection. ` +
|
|
321
|
+
`Run "openclaw carapace setup" to activate enforcement, or configure policies at http://localhost:${config.guiPort ?? 19820}`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
202
324
|
},
|
|
203
325
|
async stop() {
|
|
204
326
|
if (proxy) await proxy.stop();
|
|
@@ -514,6 +636,8 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
514
636
|
.description("Configure OpenClaw to route all traffic through Carapace")
|
|
515
637
|
.action(async () => {
|
|
516
638
|
console.log("\n🦞 Carapace Setup\n");
|
|
639
|
+
backupConfig();
|
|
640
|
+
console.log(" 📦 Backed up openclaw.json → openclaw.json.carapace-backup");
|
|
517
641
|
let anyChanges = false;
|
|
518
642
|
|
|
519
643
|
// 1. Deny built-in bypass tools
|
|
@@ -545,7 +669,8 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
545
669
|
}
|
|
546
670
|
if (patched.length === 0 && alreadySet.length === 0) {
|
|
547
671
|
console.log(" ⚠️ No upstream providers configured in proxy config.");
|
|
548
|
-
console.log(
|
|
672
|
+
console.log(' Set proxy.upstream to a URL string (e.g., "https://api.anthropic.com") with proxy.apiKey,');
|
|
673
|
+
console.log(" or use the object format: proxy.upstream = { anthropic: { apiKey: '...' } }");
|
|
549
674
|
}
|
|
550
675
|
} else {
|
|
551
676
|
console.log("\n LLM proxy not enabled — skipping baseUrl setup.");
|
|
@@ -599,11 +724,18 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
599
724
|
if (cfg.models?.providers) {
|
|
600
725
|
for (const [name, provCfg] of Object.entries(cfg.models.providers)) {
|
|
601
726
|
if ((provCfg as any)?.baseUrl === proxyUrl) {
|
|
602
|
-
|
|
727
|
+
// Restore original baseUrl if stored
|
|
728
|
+
if ((provCfg as any)._originalBaseUrl) {
|
|
729
|
+
(provCfg as any).baseUrl = (provCfg as any)._originalBaseUrl;
|
|
730
|
+
delete (provCfg as any)._originalBaseUrl;
|
|
731
|
+
console.log(` ✅ Restored original baseUrl for ${name}`);
|
|
732
|
+
} else {
|
|
733
|
+
delete (provCfg as any).baseUrl;
|
|
734
|
+
console.log(` ✅ Removed baseUrl proxy override for ${name}`);
|
|
735
|
+
}
|
|
603
736
|
// Clean up empty objects
|
|
604
737
|
if (Object.keys(provCfg as any).length === 0) delete cfg.models.providers[name];
|
|
605
738
|
changed = true;
|
|
606
|
-
console.log(` ✅ Removed baseUrl proxy override for ${name}`);
|
|
607
739
|
console.log(` ${name} will connect directly to its API again.`);
|
|
608
740
|
}
|
|
609
741
|
}
|
|
@@ -611,6 +743,68 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
611
743
|
if (cfg.models && Object.keys(cfg.models).length === 0) delete cfg.models;
|
|
612
744
|
}
|
|
613
745
|
|
|
746
|
+
// Also clean up the per-agent models.json
|
|
747
|
+
const agentModelsPath = join(homedir(), ".openclaw", "agents", "main", "agent", "models.json");
|
|
748
|
+
if (existsSync(agentModelsPath)) {
|
|
749
|
+
try {
|
|
750
|
+
const agentModels = JSON.parse(readFileSync(agentModelsPath, "utf-8"));
|
|
751
|
+
let modelsChanged = false;
|
|
752
|
+
if (agentModels.providers) {
|
|
753
|
+
for (const [name, provCfg] of Object.entries(agentModels.providers)) {
|
|
754
|
+
if ((provCfg as any)?.baseUrl === proxyUrl) {
|
|
755
|
+
if ((provCfg as any)._originalBaseUrl) {
|
|
756
|
+
(provCfg as any).baseUrl = (provCfg as any)._originalBaseUrl;
|
|
757
|
+
delete (provCfg as any)._originalBaseUrl;
|
|
758
|
+
} else {
|
|
759
|
+
delete (provCfg as any).baseUrl;
|
|
760
|
+
}
|
|
761
|
+
if (Object.keys(provCfg as any).length === 0) delete agentModels.providers[name];
|
|
762
|
+
modelsChanged = true;
|
|
763
|
+
console.log(` ✅ Cleaned proxy baseUrl from models.json for ${name}`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (modelsChanged) {
|
|
768
|
+
writeFileSync(agentModelsPath, JSON.stringify(agentModels, null, 2) + "\n", "utf-8");
|
|
769
|
+
changed = true;
|
|
770
|
+
}
|
|
771
|
+
} catch (e: any) {
|
|
772
|
+
console.log(` ⚠️ Could not clean models.json: ${e.message}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Clean up per-agent models.json (this is what actually routes API calls)
|
|
777
|
+
const agentModelsPath = join(homedir(), ".openclaw", "agents", "main", "agent", "models.json");
|
|
778
|
+
if (existsSync(agentModelsPath)) {
|
|
779
|
+
try {
|
|
780
|
+
const agentModels = JSON.parse(readFileSync(agentModelsPath, "utf-8"));
|
|
781
|
+
let modelsChanged = false;
|
|
782
|
+
if (agentModels.providers) {
|
|
783
|
+
for (const [name, provCfg] of Object.entries(agentModels.providers)) {
|
|
784
|
+
if ((provCfg as any)?.baseUrl === proxyUrl) {
|
|
785
|
+
if ((provCfg as any)._originalBaseUrl) {
|
|
786
|
+
(provCfg as any).baseUrl = (provCfg as any)._originalBaseUrl;
|
|
787
|
+
delete (provCfg as any)._originalBaseUrl;
|
|
788
|
+
} else {
|
|
789
|
+
delete (provCfg as any).baseUrl;
|
|
790
|
+
}
|
|
791
|
+
// Remove empty provider entries (but keep ones with other config)
|
|
792
|
+
const remaining = Object.keys(provCfg as any).filter(k => k !== 'models' || (provCfg as any).models?.length > 0);
|
|
793
|
+
if (remaining.length === 0) delete agentModels.providers[name];
|
|
794
|
+
modelsChanged = true;
|
|
795
|
+
console.log(` ✅ Cleaned proxy baseUrl from models.json (${name})`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (modelsChanged) {
|
|
800
|
+
writeFileSync(agentModelsPath, JSON.stringify(agentModels, null, 2) + "\n", "utf-8");
|
|
801
|
+
changed = true;
|
|
802
|
+
}
|
|
803
|
+
} catch (e: any) {
|
|
804
|
+
console.log(` ⚠️ Could not clean models.json: ${e.message}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
614
808
|
// Disable the plugin entry (don't delete — user might want to re-enable)
|
|
615
809
|
if (cfg.plugins?.entries?.carapace?.enabled) {
|
|
616
810
|
cfg.plugins.entries.carapace.enabled = false;
|
package/src/llm-proxy.ts
CHANGED
|
@@ -63,14 +63,6 @@ export class LlmProxy {
|
|
|
63
63
|
toolCallsDenied: 0,
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
-
// Audit log for GUI display
|
|
67
|
-
private auditLog: Array<{
|
|
68
|
-
timestamp: number;
|
|
69
|
-
tool: string;
|
|
70
|
-
decision: "allow" | "deny";
|
|
71
|
-
reasons: string[];
|
|
72
|
-
}> = [];
|
|
73
|
-
|
|
74
66
|
constructor(opts: LlmProxyOpts) {
|
|
75
67
|
this.port = opts.port;
|
|
76
68
|
this.upstream = opts.upstream;
|
|
@@ -78,10 +70,6 @@ export class LlmProxy {
|
|
|
78
70
|
this.logger = opts.logger;
|
|
79
71
|
}
|
|
80
72
|
|
|
81
|
-
getAuditLog() {
|
|
82
|
-
return this.auditLog.slice(-100); // last 100 entries
|
|
83
|
-
}
|
|
84
|
-
|
|
85
73
|
async start(): Promise<void> {
|
|
86
74
|
this.server = createServer(async (req, res) => {
|
|
87
75
|
try {
|
|
@@ -608,15 +596,6 @@ export class LlmProxy {
|
|
|
608
596
|
context,
|
|
609
597
|
});
|
|
610
598
|
|
|
611
|
-
// Audit log entry
|
|
612
|
-
this.auditLog.push({
|
|
613
|
-
timestamp: Date.now(),
|
|
614
|
-
tool: toolName,
|
|
615
|
-
decision: decision.decision,
|
|
616
|
-
reasons: decision.reasons,
|
|
617
|
-
});
|
|
618
|
-
if (this.auditLog.length > 500) this.auditLog.splice(0, this.auditLog.length - 100);
|
|
619
|
-
|
|
620
599
|
if (decision.decision === "deny") {
|
|
621
600
|
this.stats.toolCallsDenied++;
|
|
622
601
|
}
|
package/src/types.ts
CHANGED
|
@@ -27,10 +27,13 @@ export interface PluginConfig {
|
|
|
27
27
|
proxy?: {
|
|
28
28
|
enabled?: boolean;
|
|
29
29
|
port?: number; // default: 19821
|
|
30
|
-
|
|
30
|
+
/** String (simple: base URL) or object (multi-provider) */
|
|
31
|
+
upstream?: string | {
|
|
31
32
|
anthropic?: { url?: string; apiKey: string };
|
|
32
33
|
openai?: { url?: string; apiKey: string };
|
|
33
34
|
};
|
|
35
|
+
/** API key for the upstream provider (used with string upstream) */
|
|
36
|
+
apiKey?: string;
|
|
34
37
|
};
|
|
35
38
|
}
|
|
36
39
|
|