Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ what couples tool execution to Helio's liveness, and there is no escape hatch to
failed decision." **A Helio outage will halt tool calls mid-conversation.** For local
development without a Helio proxy, simply do not enable this plugin.

The same posture covers **misconfiguration**: if the adapter token is missing or the plugin
config is invalid, the adapter still registers — but every tool call and install is blocked, with
a loud error log. It never leaves the tool surface ungoverned (it does not rely on the host
aborting startup), so a config mistake fails closed and visibly rather than silently bypassing
governance.

## Install

Install it into your OpenClaw gateway with OpenClaw's plugin installer (it resolves the package
Expand Down Expand Up @@ -82,6 +88,30 @@ export HELIO_ADAPTER_TOKEN="<adapter-scope bearer token from Helio>"
The token is the **adapter-scope** token (`HELIO_ADAPTER_TOKEN`), never the SDK token. The
adapter never sends an `Origin` header (Helio's browser-forgery guard rejects it).

### Grounding evidence (optional)

To let a later [evidence-grounded policy](./docs/adapter-api.md#populating-evidence) enforce on
facts a tool produced, configure **success-only** extraction rules keyed by tool name. Each rule
pulls a value out of `event.result` by an **explicit segment path** (array form, not a dotted
string — a key containing `.` stays unambiguous) and grounds it under an `evidence_key`:

```jsonc
{
"evidence": {
"send_email": [
{ "key": "recipient", "path": ["to"], "ttlSeconds": 300 },
{ "key": "message_id", "path": ["id"] },
],
"http_request": [{ "key": "host", "path": ["url", "host"] }],
},
}
```

Evidence is attached only on a successful outcome; a rule whose path is absent in the result is
silently skipped (never sent as `undefined`). Each `key` **must** be named by a Helio
`evidence.requires` policy rule — an unlisted key is silently not stored by the proxy, so a later
grounded `/evaluate` would fail closed.

## How it works

| OpenClaw hook | Helio call | Result mapping |
Expand Down
31 changes: 31 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@
"minimum": 1,
"default": 2000,
"description": "Bounded timeout for POST /evaluate. On timeout the tool call fails closed (blocked)."
},
"evidence": {
"type": "object",
"default": {},
"description": "Success-only evidence-extraction rules keyed by tool name. Each rule pulls a value from the tool's result and grounds it under an evidence key (which must be named by a Helio evidence.requires policy rule, or it is silently not stored).",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"required": ["key", "path"],
"additionalProperties": false,
"properties": {
"key": {
"type": "string",
"minLength": 1,
"description": "evidence_key sent to POST /audit; must match a policy evidence.requires key."
},
"path": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"description": "Explicit segment path into event.result (e.g. [\"url\",\"host\"]). Array form, not dotted, so keys containing '.' are unambiguous. A missing path skips the entry."
},
"ttlSeconds": {
"type": "integer",
"minimum": 1,
"description": "Optional TTL forwarded as ttl_seconds on the evidence entry."
}
}
}
}
}
}
}
Expand Down
74 changes: 74 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_CONFIG, parseConfig } from './config.js'

describe('parseConfig', () => {
it('parses a full valid config including evidence rules', () => {
const result = parseConfig({
helioBaseUrl: 'http://127.0.0.1:9999',
tokenEnv: 'MY_TOKEN',
origin: 'openclaw-test',
evaluateTimeoutMs: 500,
evidence: {
send_email: [
{ key: 'recipient', path: ['to'], ttlSeconds: 300 },
{ key: 'message_id', path: ['id'] },
],
},
})

expect(result).toEqual({
ok: true,
config: {
helioBaseUrl: 'http://127.0.0.1:9999',
tokenEnv: 'MY_TOKEN',
origin: 'openclaw-test',
evaluateTimeoutMs: 500,
evidence: {
send_email: [
{ key: 'recipient', path: ['to'], ttlSeconds: 300 },
{ key: 'message_id', path: ['id'] },
],
},
},
})
})

it('applies defaults for an empty config object', () => {
expect(parseConfig({})).toEqual({ ok: true, config: DEFAULT_CONFIG })
})

it('treats undefined (no operator config) as all-defaults', () => {
expect(parseConfig(undefined)).toEqual({ ok: true, config: DEFAULT_CONFIG })
})

it('rejects an origin that violates the manifest pattern', () => {
const result = parseConfig({ origin: 'Not A Valid Origin!' })
expect(result.ok).toBe(false)
})

it('rejects a non-positive or non-integer evaluate timeout', () => {
expect(parseConfig({ evaluateTimeoutMs: 0 }).ok).toBe(false)
expect(parseConfig({ evaluateTimeoutMs: 12.5 }).ok).toBe(false)
})

it('rejects an invalid base URL', () => {
expect(parseConfig({ helioBaseUrl: 'not-a-url' }).ok).toBe(false)
})

it('rejects an evidence rule with an empty path', () => {
const result = parseConfig({ evidence: { send_email: [{ key: 'recipient', path: [] }] } })
expect(result.ok).toBe(false)
})

it('rejects an evidence rule with an empty key', () => {
const result = parseConfig({ evidence: { send_email: [{ key: '', path: ['to'] }] } })
expect(result.ok).toBe(false)
})

it('rejects an evidence rule carrying an unknown field (mirrors manifest additionalProperties:false)', () => {
const result = parseConfig({
evidence: { send_email: [{ key: 'recipient', path: ['to'], bogus: 1 }] },
})
expect(result.ok).toBe(false)
})
})
89 changes: 85 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,101 @@
import { z } from 'zod'

/**
* One success-only evidence-extraction rule. `path` addresses a value inside `event.result` by
* explicit segments (e.g. `['url', 'host']`) — array form, not a dotted string, so a key that
* itself contains a `.` is unambiguous and there is no escaping to get wrong. The extracted value
* is sent as `evidence_data` under `evidence_key: key`; a missing path skips the entry.
*/
export interface EvidenceRule {
readonly key: string
readonly path: readonly string[]
readonly ttlSeconds?: number
}

/** Evidence-extraction rules keyed by tool name (extraction is tool-shape-specific). */
export type EvidenceConfig = Readonly<Record<string, readonly EvidenceRule[]>>

// Resolved adapter configuration. Mirrors the manifest `configSchema` in openclaw.plugin.json.
export interface AdapterConfig {
helioBaseUrl: string
tokenEnv: string
origin: string
evaluateTimeoutMs: number
evidence: EvidenceConfig
}

export const DEFAULT_CONFIG: AdapterConfig = {
helioBaseUrl: 'http://127.0.0.1:3200',
tokenEnv: 'HELIO_ADAPTER_TOKEN',
origin: 'openclaw',
evaluateTimeoutMs: 2000,
evidence: {},
}

// TODO: validate the raw plugin config against the manifest schema and resolve the
// bearer token from `process.env[tokenEnv]`. Real validation lands with the config.ts TDD.
export function parseConfig(_raw: unknown): AdapterConfig {
return { ...DEFAULT_CONFIG }
// Runtime validation of operator-supplied plugin config. Mirrors the manifest `configSchema`
// (openclaw.plugin.json) as defense-in-depth — the host may validate too, but the adapter does not
// trust that. Each field defaults to DEFAULT_CONFIG when omitted; a present-but-invalid value fails.
const evidenceRuleSchema = z
.object({
key: z.string().min(1),
path: z.array(z.string()).min(1),
ttlSeconds: z.number().int().min(1).optional(),
})
.strict()

const adapterConfigSchema = z.object({
helioBaseUrl: z
.string()
.refine((s) => URL.canParse(s), 'must be a valid URL')
.default(DEFAULT_CONFIG.helioBaseUrl),
tokenEnv: z.string().min(1).default(DEFAULT_CONFIG.tokenEnv),
origin: z
.string()
.regex(/^[a-z0-9_-]{1,64}$/)
.default(DEFAULT_CONFIG.origin),
evaluateTimeoutMs: z.number().int().min(1).default(DEFAULT_CONFIG.evaluateTimeoutMs),
evidence: z.record(z.string(), z.array(evidenceRuleSchema)).default({}),
})

/** Result of resolving plugin config. Fail-closed: the caller blocks all calls when `ok` is false. */
export type ParseConfigResult =
| { readonly ok: true; readonly config: AdapterConfig }
| { readonly ok: false; readonly error: string }

/**
* Validate the host-provided plugin config (`api.pluginConfig`) into a resolved `AdapterConfig`.
* `undefined`/`null` means no operator config → all defaults. Returns `{ ok: false }` (never throws)
* on invalid input so the caller can fail closed by registering blocking hooks.
*/
export function parseConfig(raw: unknown): ParseConfigResult {
const input = raw === undefined || raw === null ? {} : raw
const parsed = adapterConfigSchema.safeParse(input)
if (!parsed.success) {
const error = parsed.error.issues
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('; ')
return { ok: false, error }
}

const d = parsed.data
// Rebuild evidence entries explicitly so optional `ttlSeconds` honors exactOptionalPropertyTypes.
const evidence: Record<string, EvidenceRule[]> = {}
for (const [toolName, rules] of Object.entries(d.evidence)) {
evidence[toolName] = rules.map((rule) => ({
key: rule.key,
path: rule.path,
...(rule.ttlSeconds !== undefined ? { ttlSeconds: rule.ttlSeconds } : {}),
}))
}

return {
ok: true,
config: {
helioBaseUrl: d.helioBaseUrl,
tokenEnv: d.tokenEnv,
origin: d.origin,
evaluateTimeoutMs: d.evaluateTimeoutMs,
evidence,
},
}
}
Loading