Skip to content
Draft
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions policy.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
23 changes: 23 additions & 0 deletions scripts/compile-policy.sh
Original file line number Diff line number Diff line change
@@ -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)"
23 changes: 23 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
})
},
})
Expand Down
94 changes: 94 additions & 0 deletions src/codemode/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'

Expand Down Expand Up @@ -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<string, unknown> | 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<string, string>,
body: mockBody,
}
}
return real.handler(...args)
},
}
}

export async function dryRun(functionCode: string): Promise<InterceptedOp[]> {
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
// ─────────────────────────────────────────────────────────────────────────
Expand Down
Binary file added src/policy/bundle.wasm
Binary file not shown.
49 changes: 49 additions & 0 deletions src/policy/evaluate.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof loadPolicy>> | null = null

async function getPolicy(): Promise<Awaited<ReturnType<typeof loadPolicy>>> {
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<PolicyResult> {
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 }
}
4 changes: 4 additions & 0 deletions src/policy/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading