@aporthq/aport-agent-guardrails 1.0.21 → 1.0.22
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 +8 -3
- package/bin/openclaw +14 -14
- package/docs/PROVIDER.md +6 -6
- package/docs/QUICKSTART_OPENCLAW_PLUGIN.md +42 -408
- package/docs/RELEASE.md +3 -2
- package/docs/frameworks/openclaw.md +123 -39
- package/extensions/openclaw-aport/CHANGELOG.md +8 -2
- package/extensions/openclaw-aport/MIGRATION.md +22 -375
- package/extensions/openclaw-aport/README.md +72 -362
- package/extensions/openclaw-aport/api-client.js +22 -0
- package/extensions/openclaw-aport/audit.js +32 -0
- package/extensions/openclaw-aport/decision.js +21 -0
- package/extensions/openclaw-aport/index.js +169 -591
- package/extensions/openclaw-aport/local-evaluator.js +303 -0
- package/extensions/openclaw-aport/openclaw.plugin.json +5 -5
- package/extensions/openclaw-aport/package-lock.json +2 -2
- package/extensions/openclaw-aport/package.json +27 -6
- package/extensions/openclaw-aport/tool-mapping.js +89 -0
- package/package.json +1 -1
- package/extensions/openclaw-aport/index.ts +0 -547
- package/extensions/openclaw-aport/test.js +0 -356
|
@@ -1,356 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Unit, integration, and performance tests for APort OpenClaw plugin.
|
|
4
|
-
* Run: node test.js
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { describe, it } from 'node:test';
|
|
8
|
-
import assert from 'node:assert';
|
|
9
|
-
import { createHash } from 'crypto';
|
|
10
|
-
import { spawn } from 'child_process';
|
|
11
|
-
import { mkdtemp, writeFile, readFile, rm, mkdir } from 'fs/promises';
|
|
12
|
-
import { join } from 'path';
|
|
13
|
-
import { tmpdir } from 'os';
|
|
14
|
-
import { fileURLToPath } from 'url';
|
|
15
|
-
import { dirname } from 'path';
|
|
16
|
-
import {
|
|
17
|
-
mapToolToPolicy,
|
|
18
|
-
canonicalize,
|
|
19
|
-
verifyDecisionIntegrity,
|
|
20
|
-
} from './index.js';
|
|
21
|
-
|
|
22
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
-
const REPO_ROOT = join(__dirname, '..', '..');
|
|
24
|
-
const GUARDRAIL_SCRIPT = join(REPO_ROOT, 'bin', 'aport-guardrail-bash.sh');
|
|
25
|
-
const INDEX_TS_PATH = join(__dirname, 'index.ts');
|
|
26
|
-
const PACKAGE_JSON_PATH = join(__dirname, 'package.json');
|
|
27
|
-
const MANIFEST_JSON_PATH = join(__dirname, 'openclaw.plugin.json');
|
|
28
|
-
|
|
29
|
-
describe('canonicalize', () => {
|
|
30
|
-
it('sorts keys at top level', () => {
|
|
31
|
-
assert.strictEqual(
|
|
32
|
-
canonicalize({ b: 1, a: 2 }),
|
|
33
|
-
'{"a":2,"b":1}',
|
|
34
|
-
);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('sorts keys recursively in nested objects', () => {
|
|
38
|
-
assert.strictEqual(
|
|
39
|
-
canonicalize({ o: { z: 1, y: 2 } }),
|
|
40
|
-
'{"o":{"y":2,"z":1}}',
|
|
41
|
-
);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('sorts keys in array elements', () => {
|
|
45
|
-
assert.strictEqual(
|
|
46
|
-
canonicalize({ reasons: [{ message: 'm', code: 'c' }] }),
|
|
47
|
-
'{"reasons":[{"code":"c","message":"m"}]}',
|
|
48
|
-
);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('handles primitives and null', () => {
|
|
52
|
-
assert.strictEqual(canonicalize(null), 'null');
|
|
53
|
-
assert.strictEqual(canonicalize(1), '1');
|
|
54
|
-
assert.strictEqual(canonicalize('x'), '"x"');
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('verifyDecisionIntegrity', () => {
|
|
59
|
-
it('returns true when content_hash is missing (legacy decision)', () => {
|
|
60
|
-
assert.strictEqual(verifyDecisionIntegrity({ allow: false }), true);
|
|
61
|
-
assert.strictEqual(verifyDecisionIntegrity(null), true);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('returns true when content_hash matches computed hash', () => {
|
|
65
|
-
const decision = {
|
|
66
|
-
allow: false,
|
|
67
|
-
decision_id: 'dec-1',
|
|
68
|
-
reasons: [{ code: 'oap.denied', message: 'test' }],
|
|
69
|
-
};
|
|
70
|
-
const canonical = canonicalize(decision);
|
|
71
|
-
const hash =
|
|
72
|
-
'sha256:' + createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
73
|
-
assert.strictEqual(
|
|
74
|
-
verifyDecisionIntegrity({ ...decision, content_hash: hash }),
|
|
75
|
-
true,
|
|
76
|
-
);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('returns false when content is tampered (hash does not match)', () => {
|
|
80
|
-
const decision = {
|
|
81
|
-
allow: false,
|
|
82
|
-
decision_id: 'dec-1',
|
|
83
|
-
reasons: [{ code: 'oap.denied', message: 'test' }],
|
|
84
|
-
content_hash: 'sha256:wrong',
|
|
85
|
-
};
|
|
86
|
-
assert.strictEqual(verifyDecisionIntegrity(decision), false);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it('returns false when a field is changed after hash was computed', () => {
|
|
90
|
-
const decision = {
|
|
91
|
-
allow: false,
|
|
92
|
-
decision_id: 'dec-1',
|
|
93
|
-
reasons: [{ code: 'oap.denied', message: 'original' }],
|
|
94
|
-
};
|
|
95
|
-
const canonical = canonicalize(decision);
|
|
96
|
-
const hash =
|
|
97
|
-
'sha256:' + createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
98
|
-
const tampered = {
|
|
99
|
-
...decision,
|
|
100
|
-
reasons: [{ code: 'oap.denied', message: 'tampered' }],
|
|
101
|
-
content_hash: hash,
|
|
102
|
-
};
|
|
103
|
-
assert.strictEqual(verifyDecisionIntegrity(tampered), false);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe('mapToolToPolicy', () => {
|
|
108
|
-
it('maps exec and exec.* to system.command.execute.v1', () => {
|
|
109
|
-
assert.strictEqual(mapToolToPolicy('exec'), 'system.command.execute.v1');
|
|
110
|
-
assert.strictEqual(mapToolToPolicy('exec.run'), 'system.command.execute.v1');
|
|
111
|
-
assert.strictEqual(mapToolToPolicy('exec.shell'), 'system.command.execute.v1');
|
|
112
|
-
assert.strictEqual(mapToolToPolicy('EXEC.RUN'), 'system.command.execute.v1');
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('maps system.command.* and bash/shell to system.command.execute.v1', () => {
|
|
116
|
-
assert.strictEqual(mapToolToPolicy('system.command.run'), 'system.command.execute.v1');
|
|
117
|
-
assert.strictEqual(mapToolToPolicy('bash'), 'system.command.execute.v1');
|
|
118
|
-
assert.strictEqual(mapToolToPolicy('shell'), 'system.command.execute.v1');
|
|
119
|
-
assert.strictEqual(mapToolToPolicy('command'), 'system.command.execute.v1');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('maps git tools to code.repository.merge.v1', () => {
|
|
123
|
-
assert.strictEqual(mapToolToPolicy('git.create_pr'), 'code.repository.merge.v1');
|
|
124
|
-
assert.strictEqual(mapToolToPolicy('git.merge'), 'code.repository.merge.v1');
|
|
125
|
-
assert.strictEqual(mapToolToPolicy('git.push'), 'code.repository.merge.v1');
|
|
126
|
-
assert.strictEqual(mapToolToPolicy('git.commit'), 'code.repository.merge.v1');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('maps messaging tools to messaging.message.send.v1', () => {
|
|
130
|
-
assert.strictEqual(mapToolToPolicy('message.send'), 'messaging.message.send.v1');
|
|
131
|
-
assert.strictEqual(mapToolToPolicy('messaging.slack'), 'messaging.message.send.v1');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('maps mcp.* to mcp.tool.execute.v1', () => {
|
|
135
|
-
assert.strictEqual(mapToolToPolicy('mcp.foo'), 'mcp.tool.execute.v1');
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('maps session/agent.session to agent.session.create.v1', () => {
|
|
139
|
-
assert.strictEqual(mapToolToPolicy('agent.session.create'), 'agent.session.create.v1');
|
|
140
|
-
assert.strictEqual(mapToolToPolicy('session.create'), 'agent.session.create.v1');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('maps payment/finance to finance policies', () => {
|
|
144
|
-
assert.strictEqual(mapToolToPolicy('payment.refund'), 'finance.payment.refund.v1');
|
|
145
|
-
assert.strictEqual(mapToolToPolicy('payment.charge'), 'finance.payment.charge.v1');
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('maps data.export and database.* to data.export.create.v1', () => {
|
|
149
|
-
assert.strictEqual(mapToolToPolicy('data.export'), 'data.export.create.v1');
|
|
150
|
-
assert.strictEqual(mapToolToPolicy('database.write'), 'data.export.create.v1');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('returns null for unmapped tools', () => {
|
|
154
|
-
assert.strictEqual(mapToolToPolicy('unknown.tool'), null);
|
|
155
|
-
assert.strictEqual(mapToolToPolicy('read_file'), null);
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe('entrypoint and manifest compliance', () => {
|
|
160
|
-
it('uses index.ts as plugin extension entrypoint', async () => {
|
|
161
|
-
const pkg = JSON.parse(await readFile(PACKAGE_JSON_PATH, 'utf8'));
|
|
162
|
-
assert.ok(pkg.openclaw, 'package.json must contain openclaw metadata');
|
|
163
|
-
assert.deepStrictEqual(
|
|
164
|
-
pkg.openclaw.extensions,
|
|
165
|
-
['./index.ts'],
|
|
166
|
-
'openclaw.extensions must point to ./index.ts',
|
|
167
|
-
);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('index.ts exports definePluginEntry default', async () => {
|
|
171
|
-
const src = await readFile(INDEX_TS_PATH, 'utf8');
|
|
172
|
-
assert.ok(
|
|
173
|
-
src.includes('import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";'),
|
|
174
|
-
'index.ts must import definePluginEntry from plugin-entry subpath',
|
|
175
|
-
);
|
|
176
|
-
assert.ok(
|
|
177
|
-
src.includes('export default definePluginEntry({'),
|
|
178
|
-
'index.ts must export definePluginEntry({...})',
|
|
179
|
-
);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('manifest configSchema covers runtime config keys', async () => {
|
|
183
|
-
const manifest = JSON.parse(await readFile(MANIFEST_JSON_PATH, 'utf8'));
|
|
184
|
-
const props = manifest?.configSchema?.properties || {};
|
|
185
|
-
const requiredKeys = [
|
|
186
|
-
'mode',
|
|
187
|
-
'passportFile',
|
|
188
|
-
'guardrailScript',
|
|
189
|
-
'apiUrl',
|
|
190
|
-
'apiKey',
|
|
191
|
-
'failClosed',
|
|
192
|
-
'allowUnmappedTools',
|
|
193
|
-
'agentId',
|
|
194
|
-
'alwaysVerifyEachToolCall',
|
|
195
|
-
'mapExecToPolicy',
|
|
196
|
-
];
|
|
197
|
-
for (const key of requiredKeys) {
|
|
198
|
-
assert.ok(Object.prototype.hasOwnProperty.call(props, key), `missing configSchema property: ${key}`);
|
|
199
|
-
}
|
|
200
|
-
assert.strictEqual(props.passportFile.default, '~/.openclaw/aport/passport.json');
|
|
201
|
-
assert.strictEqual(props.allowUnmappedTools.default, true);
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
describe('performance', () => {
|
|
206
|
-
it('mapToolToPolicy: 5000 calls complete in under 100ms', () => {
|
|
207
|
-
const tools = [
|
|
208
|
-
'exec.run',
|
|
209
|
-
'git.create_pr',
|
|
210
|
-
'message.send',
|
|
211
|
-
'unknown.tool',
|
|
212
|
-
];
|
|
213
|
-
const start = process.hrtime.bigint();
|
|
214
|
-
for (let i = 0; i < 5000; i++) {
|
|
215
|
-
mapToolToPolicy(tools[i % tools.length]);
|
|
216
|
-
}
|
|
217
|
-
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
|
|
218
|
-
assert.ok(elapsed < 100, `mapToolToPolicy 5k calls took ${elapsed.toFixed(2)}ms (expected < 100ms)`);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it('verifyDecisionIntegrity: 1000 valid checks in under 50ms', () => {
|
|
222
|
-
const decision = {
|
|
223
|
-
allow: false,
|
|
224
|
-
decision_id: 'dec-1',
|
|
225
|
-
reasons: [{ code: 'oap.denied', message: 'test' }],
|
|
226
|
-
};
|
|
227
|
-
const canonical = canonicalize(decision);
|
|
228
|
-
const hash =
|
|
229
|
-
'sha256:' + createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
230
|
-
const withHash = { ...decision, content_hash: hash };
|
|
231
|
-
const start = process.hrtime.bigint();
|
|
232
|
-
for (let i = 0; i < 1000; i++) {
|
|
233
|
-
assert.strictEqual(verifyDecisionIntegrity(withHash), true);
|
|
234
|
-
}
|
|
235
|
-
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
|
|
236
|
-
assert.ok(elapsed < 50, `verifyDecisionIntegrity 1k calls took ${elapsed.toFixed(2)}ms (expected < 50ms)`);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('canonicalize: 2000 objects in under 30ms', () => {
|
|
240
|
-
const obj = {
|
|
241
|
-
allow: false,
|
|
242
|
-
decision_id: 'dec-1',
|
|
243
|
-
reasons: [{ code: 'c', message: 'm' }],
|
|
244
|
-
policy_id: 'system.command.execute.v1',
|
|
245
|
-
};
|
|
246
|
-
const start = process.hrtime.bigint();
|
|
247
|
-
for (let i = 0; i < 2000; i++) {
|
|
248
|
-
canonicalize(obj);
|
|
249
|
-
}
|
|
250
|
-
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
|
|
251
|
-
assert.ok(elapsed < 30, `canonicalize 2k calls took ${elapsed.toFixed(2)}ms (expected < 30ms)`);
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Integration: run real guardrail script when repo is available.
|
|
257
|
-
* Ensures decision file has content_hash and chain fields; non-core writes don't block.
|
|
258
|
-
*/
|
|
259
|
-
describe('integration (guardrail script)', () => {
|
|
260
|
-
it('writes decision with content_hash and chain; audit is non-blocking', async () => {
|
|
261
|
-
const { existsSync } = await import('fs');
|
|
262
|
-
if (!existsSync(GUARDRAIL_SCRIPT)) {
|
|
263
|
-
console.log(' (skip: bin/aport-guardrail-bash.sh not found)');
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
const tmp = await mkdtemp(join(tmpdir(), 'aport-plugin-test-'));
|
|
267
|
-
const passportPath = join(tmp, 'passport.json');
|
|
268
|
-
const decisionsDir = join(tmp, 'decisions');
|
|
269
|
-
const auditLog = join(tmp, 'audit.log');
|
|
270
|
-
await mkdir(decisionsDir, { recursive: true });
|
|
271
|
-
const minimalPassport = {
|
|
272
|
-
spec_version: 'oap/1.0',
|
|
273
|
-
passport_id: 'test-passport',
|
|
274
|
-
agent_id: 'test-passport',
|
|
275
|
-
owner_id: 'test-owner',
|
|
276
|
-
status: 'active',
|
|
277
|
-
capabilities: [{ id: 'system.command.execute' }],
|
|
278
|
-
limits: {
|
|
279
|
-
'system.command.execute': {
|
|
280
|
-
allowed_commands: ['node'],
|
|
281
|
-
blocked_patterns: [],
|
|
282
|
-
},
|
|
283
|
-
},
|
|
284
|
-
};
|
|
285
|
-
await writeFile(passportPath, JSON.stringify(minimalPassport));
|
|
286
|
-
|
|
287
|
-
const runScript = (toolName, contextJson, decisionPath) =>
|
|
288
|
-
new Promise((resolve, reject) => {
|
|
289
|
-
const proc = spawn(GUARDRAIL_SCRIPT, [toolName, contextJson], {
|
|
290
|
-
env: {
|
|
291
|
-
...process.env,
|
|
292
|
-
OPENCLAW_PASSPORT_FILE: passportPath,
|
|
293
|
-
OPENCLAW_DECISION_FILE: decisionPath,
|
|
294
|
-
OPENCLAW_AUDIT_LOG: auditLog,
|
|
295
|
-
},
|
|
296
|
-
cwd: REPO_ROOT,
|
|
297
|
-
});
|
|
298
|
-
let stderr = '';
|
|
299
|
-
proc.stderr.on('data', (d) => (stderr += d));
|
|
300
|
-
proc.on('close', (code) => resolve({ code, stderr }));
|
|
301
|
-
proc.on('error', reject);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
const decisionPath1 = join(decisionsDir, 'run1.json');
|
|
305
|
-
const res1 = await runScript(
|
|
306
|
-
'system.command.execute',
|
|
307
|
-
JSON.stringify({ command: 'node --version' }),
|
|
308
|
-
decisionPath1,
|
|
309
|
-
);
|
|
310
|
-
if (res1.code !== 0) {
|
|
311
|
-
console.log(' (skip: guardrail exited with code=' + res1.code + ', stderr=' + (res1.stderr || '').slice(0, 200) + ')');
|
|
312
|
-
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
let dec1;
|
|
316
|
-
try {
|
|
317
|
-
dec1 = JSON.parse(await readFile(decisionPath1, 'utf8'));
|
|
318
|
-
} catch (e) {
|
|
319
|
-
console.log(' (skip: decision file missing or invalid JSON)');
|
|
320
|
-
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
if (!dec1.content_hash) {
|
|
324
|
-
console.log(' (skip: decision has no content_hash - need guardrail with tamper-resistant writes)');
|
|
325
|
-
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
if (dec1.allow !== true) {
|
|
329
|
-
console.log(' (skip: guardrail denied - passport/limits may not allow node)');
|
|
330
|
-
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
assert.ok(verifyDecisionIntegrity(dec1), 'content_hash must verify');
|
|
334
|
-
|
|
335
|
-
const decisionPath2 = join(decisionsDir, 'run2.json');
|
|
336
|
-
const res2 = await runScript(
|
|
337
|
-
'system.command.execute',
|
|
338
|
-
JSON.stringify({ command: 'node --version' }),
|
|
339
|
-
decisionPath2,
|
|
340
|
-
);
|
|
341
|
-
if (res2.code !== 0) {
|
|
342
|
-
console.log(' (skip: second run exited code=' + res2.code + ')');
|
|
343
|
-
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
const dec2 = JSON.parse(await readFile(decisionPath2, 'utf8'));
|
|
347
|
-
assert.ok(dec2.content_hash);
|
|
348
|
-
assert.ok(
|
|
349
|
-
dec2.prev_decision_id != null || dec2.prev_content_hash != null,
|
|
350
|
-
'second decision should chain',
|
|
351
|
-
);
|
|
352
|
-
assert.ok(verifyDecisionIntegrity(dec2), 'second decision must verify');
|
|
353
|
-
|
|
354
|
-
await rm(tmp, { recursive: true, force: true });
|
|
355
|
-
});
|
|
356
|
-
});
|