diff --git a/package.json b/package.json index 4998dcd..a04b377 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ }, "dependencies": { "@iso4/sandbox": "catalog:", - "@napi-rs/keyring": "catalog:" + "@napi-rs/keyring": "catalog:", + "@open-policy-agent/opa-wasm": "catalog:" }, "devDependencies": { "@c8y/client": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 869f0d2..8517b45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ catalogs: '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 + '@open-policy-agent/opa-wasm': + specifier: ^1.10.0 + version: 1.10.0 '@schplitt/eslint-config': specifier: ^1.5.1 version: 1.5.1 @@ -89,6 +92,9 @@ importers: '@napi-rs/keyring': specifier: 'catalog:' version: 1.3.0 + '@open-policy-agent/opa-wasm': + specifier: 'catalog:' + version: 1.10.0 devDependencies: '@c8y/client': specifier: 'catalog:' @@ -753,6 +759,9 @@ packages: resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} engines: {node: ^20.17.0 || >=22.9.0} + '@open-policy-agent/opa-wasm@1.10.0': + resolution: {integrity: sha512-ymR/nFS3nO9o24j9xowGGQaf+Gmb813QcxUpVZkfRlJkawKWqSIllnEH15agyWjijmOIyhA+OBErenx6N3jphw==} + '@ota-meshi/ast-token-store@0.3.0': resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -3105,6 +3114,9 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sqids@0.3.0: resolution: {integrity: sha512-lOQK1ucVg+W6n3FhRwwSeUijxe93b51Bfz5PMRMihVf1iVkl82ePQG7V5vwrhzB11v0NtsR25PSZRGiSomJaJw==} @@ -3512,6 +3524,10 @@ packages: resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -4234,6 +4250,11 @@ snapshots: node-gyp: 12.4.0 proc-log: 6.1.0 + '@open-policy-agent/opa-wasm@1.10.0': + dependencies: + sprintf-js: 1.1.3 + yaml: 1.10.3 + '@ota-meshi/ast-token-store@0.3.0': {} '@oxc-project/types@0.133.0': {} @@ -6865,6 +6886,8 @@ snapshots: spdx-license-ids@3.0.23: {} + sprintf-js@1.1.3: {} + sqids@0.3.0: {} srvx@0.11.16: {} @@ -7221,6 +7244,8 @@ snapshots: eslint-visitor-keys: 5.0.1 yaml: 2.9.0 + yaml@1.10.3: {} + yaml@2.9.0: {} yargs-parser@22.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 228b1dc..5ab2d20 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,7 @@ catalog: '@iso4/fetch': ^0.0.1 '@iso4/sandbox': ^0.0.3 '@napi-rs/keyring': ^1.3.0 + '@open-policy-agent/opa-wasm': ^1.10.0 '@schplitt/eslint-config': ^1.5.1 '@tmcp/adapter-valibot': ^0.1.6 '@tmcp/transport-http': ^0.8.6 diff --git a/policy.json b/policy.json new file mode 100644 index 0000000..0b382b1 --- /dev/null +++ b/policy.json @@ -0,0 +1,21 @@ +{ + "allowed_tenants": [ + "https://dtm-sb5.preprod.c8y.io" + ], + "path_policies": [ + { "action": "allow", "method": "GET", "path_glob": "/**" }, + { "action": "allow", "method": "POST", "path_glob": "/**" }, + { "action": "elicit", "method": "PUT", "path_glob": "/**" }, + { "action": "elicit", "method": "DELETE", "path_glob": "/**" } + ], + "limits": { + "max_deletes_per_transaction": 10 + }, + "restricted_body_fields": [ + "id", + "self", + "owner", + "lastUpdated", + "creationTime" + ] +} diff --git a/scripts/compile-policy.sh b/scripts/compile-policy.sh new file mode 100755 index 0000000..cdf052f --- /dev/null +++ b/scripts/compile-policy.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Recompile src/policy/bundle.wasm from src/policy/rego/main.rego. +# Requires opa CLI (https://www.openpolicyagent.org/docs/latest/#1-download-opa). +# Only needs to be run when the .rego source changes — the compiled artifact is +# committed to the repo so normal builds do not require opa to be installed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +REGO_DIR="$PROJECT_ROOT/src/policy/rego" +OUT_WASM="$PROJECT_ROOT/src/policy/bundle.wasm" +TMP_BUNDLE="$(mktemp /tmp/mc8yp-policy-XXXXXX.tar.gz)" + +echo "Compiling OPA policy to WASM..." +opa build -t wasm -e mc8yp/transaction/decision -o "$TMP_BUNDLE" "$REGO_DIR" + +echo "Extracting policy.wasm..." +TMP_DIR="$(mktemp -d)" +tar -xzf "$TMP_BUNDLE" -C "$TMP_DIR" +cp "$TMP_DIR/policy.wasm" "$OUT_WASM" +rm -rf "$TMP_DIR" "$TMP_BUNDLE" + +echo "Done: $OUT_WASM ($(wc -c < "$OUT_WASM") bytes)" diff --git a/src/cli/index.ts b/src/cli/index.ts index 09baaa3..6ac92a0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { StdioTransport } from '@tmcp/transport-stdio' import { defineCommand, runMain } from 'citty' import consola from 'consola' @@ -34,6 +36,11 @@ const main = defineCommand({ alias: 's', default: getCoreOpenApiVersion(), }, + policyData: { + type: 'string', + description: 'Path to an OPA data.json file. When supplied, OPA decides whether to allow, elicit, or deny mutating operations instead of always prompting.', + alias: ['p', 'policy-data'], + }, }, setup: () => { globalThis._getCredentialsByTenantUrl = getCredentialsByTenantUrl @@ -77,6 +84,21 @@ const main = defineCommand({ consola.info(`Applying ${parsedAllowRules.length} allow rule(s):`, parsedAllowRules.map((r) => r.source)) } + const rawPolicyData = Array.isArray(args.policyData) ? args.policyData.at(-1) : args.policyData + let policyDataPath: string | undefined + if (rawPolicyData) { + policyDataPath = resolve(rawPolicyData) + if (!existsSync(policyDataPath)) { + throw new Error(`--policy-data: file not found: ${policyDataPath}`) + } + try { + JSON.parse(readFileSync(policyDataPath, 'utf8')) + } catch { + throw new Error(`--policy-data: file is not valid JSON: ${policyDataPath}`) + } + consola.info(`OPA policy data loaded: ${policyDataPath}`) + } + // If a tenant was previously selected, populate the in-memory context now // so the first tool call is immediately ready — discovery cost is paid here // at startup, not deferred to the first tool call. @@ -119,6 +141,7 @@ const main = defineCommand({ // the agent cannot misuse this state for real calls. specs: active?.specs ?? getBundledOnlySpecs(), auth: active ? { tenantUrl: active.tenantUrl, authorizationHeader: active.authorizationHeader } : undefined, + policyDataPath, }) }, }) diff --git a/src/codemode/execute.ts b/src/codemode/execute.ts index d994398..6ab2f32 100644 --- a/src/codemode/execute.ts +++ b/src/codemode/execute.ts @@ -12,6 +12,7 @@ import type { AllowRule, RestrictionRule } from '../utils/restrictions' const QUERY_ENTRY_PATH = '/codemode-query.mjs' const EXECUTE_ENTRY_PATH = '/codemode-execute.mjs' +const DRY_RUN_ENTRY_PATH = '/codemode-dryrun.mjs' export const BLOCKED_REQUEST_PREFIX = 'Request blocked by MCP connection policy.' @@ -256,6 +257,99 @@ function buildCumulocityPreamble(tenantUrl: string): string { })()` } +// ───────────────────────────────────────────────────────────────────────── +// Dry-run interception +// ───────────────────────────────────────────────────────────────────────── + +export interface InterceptedOp { + method: string + path: string + body: unknown +} + +// Methods intercepted in dry-run (mocked — no real HTTP call). +// POST is included because upsert/action POSTs are common in Cumulocity (e.g. +// X-Upsert-Mode) and must not fire twice. The mock echoes the request body so +// compositions like `const r = await POST(...); use(r.id)` keep working. +// PATCH is included to keep the dry-run fully side-effect-free even when code +// mixes PATCH with other methods; approval is only required for POST/PUT/DELETE. +const DRY_RUN_INTERCEPT = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) + +function createInterceptingFetch( + real: SafeFetchGlobal, + tenantUrl: string, + ops: InterceptedOp[], +): SafeFetchGlobal { + const base = tenantUrl.endsWith('/') ? tenantUrl.slice(0, -1) : tenantUrl + return { + kind: 'bridge-with-shim' as const, + shim: real.shim, + handler: async (...args: unknown[]) => { + const url = String(args[0] ?? '') + const init = (args[1] as Record | undefined) ?? {} + const method = String(init.method ?? 'GET').toUpperCase() + + if (DRY_RUN_INTERCEPT.has(method)) { + const path = url.startsWith(base) ? url.slice(base.length) : url + let body: unknown = null + const rawBody = init.body + if (typeof rawBody === 'string' && rawBody.length > 0) { + try { + body = JSON.parse(rawBody) + } catch { + body = rawBody + } + } + ops.push({ method, path, body }) + // Echo the request body so downstream composition code (e.g. + // `const r = await POST(...); r.description`) keeps working. + // DELETE → 204 No Content; POST → 201 Created; PUT/PATCH → 200 OK. + const mockBody = method === 'DELETE' ? null : (body ?? {}) + return { + status: method === 'DELETE' ? 204 : method === 'POST' ? 201 : 200, + statusText: method === 'DELETE' ? 'No Content' : method === 'POST' ? 'Created' : 'OK', + headers: {} as Record, + body: mockBody, + } + } + return real.handler(...args) + }, + } +} + +export async function dryRun(functionCode: string): Promise { + const auth = await resolveC8yAuth() + const authHeaders = createC8yAuthHeaders(auth) + const restrictions = c8yMcpServer.ctx.custom?.restrictions ?? [] + const allowRules = c8yMcpServer.ctx.custom?.allowRules ?? [] + + const ops: InterceptedOp[] = [] + const realFetch = createCumulocitySafeFetch(auth.tenantUrl, authHeaders, restrictions, allowRules) + + const functionExpression = normalizeCode(functionCode) + const code = [ + `const __mc8ypExecute = (${functionExpression});`, + 'if (typeof __mc8ypExecute !== "function") { throw new TypeError("Execute code must evaluate to a function.") }', + 'export default await __mc8ypExecute();', + ].join('\n') + + const globals: HostGlobals = { + __c8y_fetch: createInterceptingFetch(realFetch, auth.tenantUrl, ops), + cumulocity: buildCumulocityPreamble(auth.tenantUrl), + } + + const sandbox = await getSandbox() + // Ignore sandbox errors — partial captures are still useful. + await sandbox.run({ + code, + filename: DRY_RUN_ENTRY_PATH, + limits: SANDBOX_LIMITS, + globals, + }).catch(() => undefined) + + return ops +} + // ───────────────────────────────────────────────────────────────────────── // Public surface // ───────────────────────────────────────────────────────────────────────── diff --git a/src/policy/bundle.wasm b/src/policy/bundle.wasm new file mode 100644 index 0000000..4502473 Binary files /dev/null and b/src/policy/bundle.wasm differ diff --git a/src/policy/evaluate.ts b/src/policy/evaluate.ts new file mode 100644 index 0000000..4f85713 --- /dev/null +++ b/src/policy/evaluate.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { loadPolicy } from '@open-policy-agent/opa-wasm' +import type { OpaTransactionPlan } from './transaction-plan' + +export type PolicyAction = 'allow' | 'elicit' | 'deny' + +export interface PolicyResult { + action: PolicyAction + /** Populated when action is 'deny'; empty otherwise. */ + denyReasons: string[] +} + +// Lazily loaded and cached — the WASM module is heavy; we only instantiate it +// once and reuse it across evaluate() calls within the same process. +let _policy: Awaited> | null = null + +async function getPolicy(): Promise>> { + if (!_policy) { + const wasmPath = fileURLToPath(new URL('./bundle.wasm', import.meta.url)) + _policy = await loadPolicy(readFileSync(wasmPath)) + } + return _policy +} + +/** + * Evaluates the bundled OPA policy against the given transaction plan and + * the data document loaded from `dataPath`. + * + * The policy exposes a single `decision` entrypoint shaped as + * `{ action, reasons }`. Returns `elicit` (the safe default) when the result + * is missing or malformed. + */ +export async function evaluatePolicy( + plan: OpaTransactionPlan, + dataPath: string, +): Promise { + const policy = await getPolicy() + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as object + policy.setData(data) + + const results = policy.evaluate(plan.input) as Array<{ result?: { action?: unknown, reasons?: unknown } }> | null + const decision = results?.[0]?.result + const raw = decision?.action + const action: PolicyAction = raw === 'allow' || raw === 'deny' || raw === 'elicit' ? raw : 'elicit' + const denyReasons = action === 'deny' && Array.isArray(decision?.reasons) ? (decision.reasons as string[]) : [] + + return { action, denyReasons } +} diff --git a/src/policy/index.ts b/src/policy/index.ts new file mode 100644 index 0000000..49ed9f2 --- /dev/null +++ b/src/policy/index.ts @@ -0,0 +1,4 @@ +export { buildOpaTransactionPlan } from './transaction-plan' +export type { OpaTransactionPlan } from './transaction-plan' +export { evaluatePolicy } from './evaluate' +export type { PolicyAction, PolicyResult } from './evaluate' diff --git a/src/policy/rego/main.rego b/src/policy/rego/main.rego new file mode 100644 index 0000000..ec04409 --- /dev/null +++ b/src/policy/rego/main.rego @@ -0,0 +1,96 @@ +package mc8yp.transaction + +import rego.v1 + +# ───────────────────────────────────────────────────────────────────────────── +# Single decision object — the only entrypoint, consumed by evaluate.ts. +# action : "allow" | "elicit" | "deny" +# reasons : human-readable deny messages (empty unless action == "deny") +# +# Priority: deny > elicit > allow +# deny — a hard block (tenant / bulk-delete limit / restricted field) or an +# explicit "deny" path_policy matches any transaction +# elicit — no deny, but some transaction matches an "elicit" rule, or matches +# no rule at all (the safe default) +# allow — no deny, no elicit, and every transaction matches an "allow" rule +# ───────────────────────────────────────────────────────────────────────────── + +decision := {"action": action, "reasons": reasons} + +default action := "elicit" + +action := "deny" if count(reasons) > 0 + +action := "allow" if { + count(reasons) == 0 + not _any_elicit + _all_allow +} + +# ── Deny reasons (each clause adds a message; any message ⇒ deny) ───────────── + +reasons contains msg if { + not _tenant_allowed + msg := sprintf( + "Tenant '%v' is not in the allowed list", + [input.principal.tenant], + ) +} + +reasons contains msg if { + deletes := count([tx | some tx in input.transactions; tx.method == "DELETE"]) + deletes > data.limits.max_deletes_per_transaction + msg := sprintf( + "Bulk DELETE limit exceeded: %v requested, max %v allowed", + [deletes, data.limits.max_deletes_per_transaction], + ) +} + +reasons contains msg if { + some tx in input.transactions + tx.method in {"POST", "PUT", "PATCH"} + some field in data.restricted_body_fields + _ = tx.body[field] + msg := sprintf( + "%v %v attempts to set restricted field '%v'", + [tx.method, tx.path, field], + ) +} + +reasons contains msg if { + some tx in input.transactions + some rule in data.path_policies + rule.action == "deny" + _matches(rule, tx) + msg := sprintf( + "%v %v is denied by policy", + [tx.method, tx.pathTemplate], + ) +} + +# ── Elicit / allow checks ───────────────────────────────────────────────────── + +_any_elicit if { + some tx in input.transactions + some rule in data.path_policies + rule.action == "elicit" + _matches(rule, tx) +} + +_all_allow if { + every tx in input.transactions { + some rule in data.path_policies + rule.action == "allow" + _matches(rule, tx) + } +} + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +_tenant_allowed if input.principal.tenant in data.allowed_tenants + +# A rule matches a transaction when method (or "*") and the path glob both match. +_matches(rule, tx) if { + rule.method in {"*", tx.method} + glob.match(rule.path_glob, ["/"], tx.pathTemplate) +} diff --git a/src/policy/transaction-plan.ts b/src/policy/transaction-plan.ts new file mode 100644 index 0000000..e47634f --- /dev/null +++ b/src/policy/transaction-plan.ts @@ -0,0 +1,75 @@ +import type { InterceptedOp } from '../codemode/execute' + +interface OpaTransaction { + id: string + method: string + /** + * Exact path from the request, without query string. + */ + path: string + /** + * Path with numeric/UUID segments replaced by `*` so that glob patterns + * in data.json rules can match them directly. + * Example: `/inventory/managedObjects/12345` → `/inventory/managedObjects/*` + */ + pathTemplate: string + /** + * Extracted resource ID, or null when path has no trailing ID segment. + */ + resourceId: string | null + /** + * Parsed query parameters, or null when none present. + */ + queryParams: Record | null + /** + * Parsed request body, or null for bodyless methods. + */ + body: unknown +} + +export interface OpaTransactionPlan { + input: { + principal: { tenant: string } + transactions: OpaTransaction[] + context: { tool: 'mc8yp-execute' } + } +} + +function canonicalize(path: string): { base: string, id: string | null } { + const [pathPart, qs] = path.split('?', 2) as [string, string | undefined] + const segments = pathPart.split('/') + const last = segments[segments.length - 1] ?? '' + const isId = /^\d+$/.test(last) || /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(last) + if (isId) { + // Use `*` (not `{id}`) so the value works as a glob target in data.json rules. + segments[segments.length - 1] = '*' + return { base: segments.join('/') + (qs != null ? `?${qs}` : ''), id: last } + } + return { base: path, id: null } +} + +export function buildOpaTransactionPlan(ops: InterceptedOp[], tenantUrl: string): OpaTransactionPlan { + return { + input: { + principal: { tenant: tenantUrl }, + transactions: ops.map((op, i) => { + const [pathPart, qs] = op.path.split('?', 2) as [string, string | undefined] + const { base, id: resourceId } = canonicalize(op.path) + const pathTemplate = base.split('?')[0] as string + const queryParams = qs + ? Object.fromEntries(new URLSearchParams(qs)) + : null + return { + id: `op-${i}`, + method: op.method, + path: pathPart, + pathTemplate, + resourceId, + queryParams, + body: op.body, + } + }), + context: { tool: 'mc8yp-execute' }, + }, + } +} diff --git a/src/tools/codemode.ts b/src/tools/codemode.ts index da5e5d4..5736f3b 100644 --- a/src/tools/codemode.ts +++ b/src/tools/codemode.ts @@ -2,6 +2,7 @@ import { defineTool } from 'tmcp/tool' import { tool } from 'tmcp/utils' import * as v from 'valibot' import { execute, query } from '../codemode/execute' +import { evaluatePolicies } from './intercept' import type { Env } from '../types' function createCodeSchema(description: string) { @@ -149,6 +150,9 @@ async () => { }), }, async (input) => { try { + const blocked = await evaluatePolicies(input.code) + if (blocked) + return tool.error(blocked) return tool.text(await execute(input.code)) } catch (error) { return tool.error(error instanceof Error ? error.message : String(error)) diff --git a/src/tools/intercept.ts b/src/tools/intercept.ts new file mode 100644 index 0000000..02354c3 --- /dev/null +++ b/src/tools/intercept.ts @@ -0,0 +1,188 @@ +import * as v from 'valibot' +import { dryRun } from '../codemode/execute' +import type { InterceptedOp } from '../codemode/execute' +import { c8yMcpServer } from '../server-instance' +import { buildOpaTransactionPlan, evaluatePolicy } from '../policy' + +// ───────────────────────────────────────────────────────────────────────── +// Mutating-operation summary for elicitation +// ───────────────────────────────────────────────────────────────────────── + +const MAX_INLINE_IDS = 5 +const MAX_BODY_FIELDS = 5 +const MAX_STRING_LEN = 60 + +function truncate(s: string): string { + return s.length > MAX_STRING_LEN ? `${s.slice(0, MAX_STRING_LEN)}…` : s +} + +function summarizeValue(val: unknown): string { + if (val === null || val === undefined) + return 'null' + if (typeof val === 'string') + return `"${truncate(val)}"` + if (typeof val === 'number' || typeof val === 'boolean') + return String(val) + if (Array.isArray(val)) + return `[${val.length} items]` + if (typeof val === 'object') + return `{${Object.keys(val as object).join(', ')}}` + return String(val) +} + +function bodyLines(body: unknown): string[] { + if (!body || typeof body !== 'object' || Array.isArray(body)) + return [] + const entries = Object.entries(body as Record) + const shown = entries.slice(0, MAX_BODY_FIELDS).map(([k, val]) => ` ${k}: ${summarizeValue(val)}`) + if (entries.length > MAX_BODY_FIELDS) + shown.push(` … (${entries.length - MAX_BODY_FIELDS} more fields)`) + return shown +} + +function canonicalize(path: string): { base: string, id: string | null } { + const [pathPart, qs] = path.split('?', 2) as [string, string | undefined] + const segments = pathPart.split('/') + const last = segments[segments.length - 1] ?? '' + const isId = /^\d+$/.test(last) || /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(last) + if (isId) { + segments[segments.length - 1] = '{id}' + return { base: segments.join('/') + (qs != null ? `?${qs}` : ''), id: last } + } + return { base: path, id: null } +} + +function formatMutatingOpsSummary(ops: InterceptedOp[]): string { + const postCount = ops.filter((o) => o.method === 'POST').length + const putCount = ops.filter((o) => o.method === 'PUT').length + const delCount = ops.filter((o) => o.method === 'DELETE').length + const parts = [ + delCount > 0 ? `${delCount} DELETE` : '', + putCount > 0 ? `${putCount} PUT` : '', + postCount > 0 ? `${postCount} POST` : '', + ].filter(Boolean).join(', ') + + const lines: string[] = [ + `${ops.length} mutating API operation${ops.length !== 1 ? 's' : ''} detected (${parts}):`, + '', + ] + + interface Group { method: string, base: string, ids: string[], ops: InterceptedOp[] } + const groups = new Map() + for (const op of ops) { + const { base, id } = canonicalize(op.path) + const key = `${op.method}:${base}` + if (!groups.has(key)) + groups.set(key, { method: op.method, base, ids: [], ops: [] }) + const g = groups.get(key)! + if (id) + g.ids.push(id) + g.ops.push(op) + } + + for (const g of groups.values()) { + const first = g.ops[0] + if (!first) + continue + if (g.ops.length === 1) { + lines.push(`${g.method} ${first.path}`) + if (g.method !== 'DELETE') + lines.push(...bodyLines(first.body)) + } else { + lines.push(`${g.method} ${g.base} ×${g.ops.length}`) + if (g.ids.length > 0) { + const shown = g.ids.slice(0, MAX_INLINE_IDS) + const rest = g.ids.length - shown.length + lines.push(` IDs: ${shown.join(', ')}${rest > 0 ? ` … (+${rest} more)` : ''}`) + } + if (g.method !== 'DELETE') { + const sample = bodyLines(first.body) + if (sample.length > 0) { + lines.push(' Body (first operation):') + lines.push(...sample) + } + } + } + } + + return lines.join('\n') +} + +// ───────────────────────────────────────────────────────────────────────── +// Elicitation workflow +// ───────────────────────────────────────────────────────────────────────── + +/** + * Runs a dry-run of the given code and, if POST/PUT/DELETE operations are + * detected, applies the OPA policy (when `--policy-data` was supplied) or + * falls back to MCP elicitation. + * + * OPA decisions: + * allow — proceed silently, no prompt + * elicit — show the approval prompt (same as the no-policy fallback) + * deny — auto-reject without prompting + * + * Returns null if execution should proceed, or an error string if blocked. + * @param code The JavaScript code to analyze, as a zero-parameter function expression. + */ +export async function evaluatePolicies(code: string): Promise { + const ops = await dryRun(code) + const mutating = ops.filter((o) => o.method === 'POST' || o.method === 'PUT' || o.method === 'DELETE') + + if (mutating.length === 0) + return null + + const tenantUrl = c8yMcpServer.ctx.custom?.auth?.tenantUrl ?? '(unknown)' + const policyDataPath = c8yMcpServer.ctx.custom?.policyDataPath + + // ── OPA policy evaluation ────────────────────────────────────────────── + if (policyDataPath) { + const plan = buildOpaTransactionPlan(mutating, tenantUrl) + let decision + try { + decision = await evaluatePolicy(plan, policyDataPath) + } catch (err) { + const reason = err instanceof Error ? err.message : String(err) + return `Execution blocked: OPA policy evaluation failed — ${reason}` + } + + if (decision.action === 'allow') + return null + if (decision.action === 'deny') { + const reasons = decision.denyReasons.length > 0 + ? `\n${decision.denyReasons.map((r) => `- ${r}`).join('\n')}` + : '' + return `Execution blocked by policy.${reasons}` + } + // action === 'elicit' → fall through to the elicitation prompt below + } + + // ── Elicitation (no policy data, or policy says "elicit") ───────────── + const message = [ + formatMutatingOpsSummary(mutating), + ].join('\n') + + // v.optional allows undefined content when action is 'decline' or 'cancel', + // avoiding a spurious validation throw inside tmcp that would mask the real action. + const schema = v.optional(v.object({})) + + let approval + try { + approval = await c8yMcpServer.elicitation(message, schema) + } catch { + return ( + 'Execution blocked: mutating operations (POST/PUT/DELETE) require your approval, ' + + 'but the MCP client does not support elicitation. ' + + 'Use a client that supports MCP elicitation (e.g. Claude Desktop) to run mutating operations.' + ) + } + + if (approval.action === 'decline') + return 'Execution cancelled: you declined the approval prompt.' + if (approval.action === 'cancel') + return 'Execution cancelled: the approval prompt was dismissed.' + if (approval.action !== 'accept') + return 'Execution was not approved.' + + return null +} diff --git a/src/types/mcp-context.ts b/src/types/mcp-context.ts index c6b32da..f72471d 100644 --- a/src/types/mcp-context.ts +++ b/src/types/mcp-context.ts @@ -42,4 +42,10 @@ export interface C8yMcpCustomContext extends Record { * May be absent in CLI mode until set-active-tenant is called. */ auth?: RequestAuth + /** + * Path to the OPA data.json file for policy-based approval decisions. + * CLI-only; set via --policy-data flag. When absent, all mutating ops + * require elicitation (legacy behaviour). + */ + policyDataPath?: string } diff --git a/tsdown.config.ts b/tsdown.config.ts index 8e2cd20..7c655f6 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { copyFileSync, readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'tsdown' @@ -158,6 +158,21 @@ export function coreOpenApiPlugin(options: { mode: 'cli' } | { mode: 'server', b return createOpenApiPlugin('core', options) } +/** + * Rollup plugin: copies src/policy/bundle.wasm → dist/bundle.wasm after the + * CLI build. The evaluate module resolves it via import.meta.url at runtime. + */ +function copyPolicyWasmPlugin() { + return { + name: 'mc8yp:copy-policy-wasm', + closeBundle() { + const src = path.join(rootDir, 'src/policy/bundle.wasm') + const dest = path.join(rootDir, 'dist/bundle.wasm') + copyFileSync(src, dest) + }, + } +} + /** * Virtual module `#bundled-services` — exports BUNDLED_SERVICE_SPECS. * @@ -242,7 +257,7 @@ export default defineConfig([ clean: true, dts: false, format: 'module', - plugins: [coreOpenApiPlugin({ mode: 'cli' }), bundledServicesPlugin({ mode: 'cli' })], + plugins: [coreOpenApiPlugin({ mode: 'cli' }), bundledServicesPlugin({ mode: 'cli' }), copyPolicyWasmPlugin()], // Bundle every non-native dep to reduce supply-chain risk for CLI users. // @iso4/sandbox must stay external: it resolves per-platform Rust binaries // (@iso4/v8-*) at runtime and cannot be statically inlined.