From bb2b6bc9c714afcf24b1fb5a1e2e61298bb692b0 Mon Sep 17 00:00:00 2001 From: Amit Saxena Date: Wed, 18 Mar 2026 00:22:13 +0530 Subject: [PATCH] wip js sdk with rust bug fixes --- .gitignore | 5 +- Cargo.toml | 2 +- README.md | 79 ++++++++---- bindings/wasm/src/lib.rs | 9 +- core/src/ir.rs | 1 + scripts/wasm_pack.sh | 11 +- sdk/go/sdk-blueprint.go | 117 +++++++++++++++++ sdk/js/common/package.json | 10 ++ sdk/js/common/src/actra.ts | 87 +++++++++++++ sdk/js/common/src/engine.ts | 55 ++++++++ sdk/js/common/src/errors.ts | 16 +++ sdk/js/common/src/events.ts | 28 ++++ sdk/js/common/src/index.ts | 19 +++ sdk/js/common/src/loader.ts | 49 +++++++ sdk/js/common/src/policy.ts | 41 ++++++ sdk/js/common/src/runtime.ts | 213 +++++++++++++++++++++++++++++++ sdk/js/common/src/types.ts | 40 ++++++ sdk/js/common/src/validators.ts | 26 ++++ sdk/js/package.json | 15 +++ sdk/js/sdk-blueprint.ts | 120 +++++++++++++++++ sdk/js/server/package.json | 41 ++++++ sdk/js/server/src/engine.ts | 50 ++++++++ sdk/js/server/src/index.ts | 18 +++ sdk/js/server/src/wasm-loader.ts | 9 ++ sdk/js/server/tsconfig.json | 7 + sdk/js/server/tsup.config.ts | 13 ++ sdk/js/tsconfig.json | 15 +++ sdk/python/actra/blueprint.py | 120 +++++++++++++++++ sdk/python/actra/events.py | 5 +- sdk/python/actra/runtime.py | 11 +- 30 files changed, 1199 insertions(+), 33 deletions(-) create mode 100644 sdk/go/sdk-blueprint.go create mode 100644 sdk/js/common/package.json create mode 100644 sdk/js/common/src/actra.ts create mode 100644 sdk/js/common/src/engine.ts create mode 100644 sdk/js/common/src/errors.ts create mode 100644 sdk/js/common/src/events.ts create mode 100644 sdk/js/common/src/index.ts create mode 100644 sdk/js/common/src/loader.ts create mode 100644 sdk/js/common/src/policy.ts create mode 100644 sdk/js/common/src/runtime.ts create mode 100644 sdk/js/common/src/types.ts create mode 100644 sdk/js/common/src/validators.ts create mode 100644 sdk/js/package.json create mode 100644 sdk/js/sdk-blueprint.ts create mode 100644 sdk/js/server/package.json create mode 100644 sdk/js/server/src/engine.ts create mode 100644 sdk/js/server/src/index.ts create mode 100644 sdk/js/server/src/wasm-loader.ts create mode 100644 sdk/js/server/tsconfig.json create mode 100644 sdk/js/server/tsup.config.ts create mode 100644 sdk/js/tsconfig.json create mode 100644 sdk/python/actra/blueprint.py diff --git a/.gitignore b/.gitignore index 0b2cbe7..8ea831d 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ venv_test wheelhouse ____*/ ____* -bindings/wasm/pkg/ \ No newline at end of file +bindings/wasm/pkg/ +sdk/js/server/pkg/ +sdk/js/server/dist/ +sdk/js/web/pkg/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index d630af1..bb5d439 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.5.1" +version = "0.6.0" edition = "2021" license = "Apache-2.0" authors = ["Amit Saxena"] diff --git a/README.md b/README.md index 31560c9..b3dd6d5 100644 --- a/README.md +++ b/README.md @@ -3,25 +3,36 @@ [![License](https://img.shields.io/github/license/getactra/actra)](https://github.com/getactra/actra/blob/main/LICENSE) # Actra -Decision Control +### Decision Control for Software -**Admission Control for Agentic & Automated Systems** +![Actra Policy Enforced](https://img.shields.io/badge/Actra-Policy%20Enforced-16a34a?style=flat-square) -Evaluate decisions before operations execute. -Prevent unsafe actions and enforce safe policies across APIs, services, automation pipelines, and AI agents. +**Admission Control for Agentic and Automated Systems** -Actra evaluates policies **before operations execute**, allowing or blocking actions triggered by APIs, automation systems, or AI agents. +Actra introduces **Decision Control** — a runtime layer that evaluates policies **before operations execute**. +It allows systems to **permit or block actions safely**, preventing unsafe operations triggered by AI agents, APIs & automation systems -Actra prevents unsafe operations in: +Instead of embedding control logic directly in application code, Actra evaluates **external policies** before state-changing actions run. -* AI agents -* APIs -* automation systems -* background workers -* workflows +## Where Actra applies -Instead of embedding control logic in application code, Actra evaluates **external policies** before state-changing actions run. +Actra protects operations in systems such as: + +- AI agents +- APIs and services +- automation pipelines +- background workers +- workflows and schedulers + + --- @@ -29,7 +40,7 @@ Instead of embedding control logic in application code, Actra evaluates **extern ![MCP Demo](doc/mcp-demo.gif) -An AI agent attempted to call an MCP tool. +### An AI agent attempted to call an MCP tool. Actra evaluated policy and **blocked the unsafe operation before execution**. @@ -232,17 +243,41 @@ Actra can control many automated operations. --- -## SDKs +## Actra Platform Support + +Actra runs across **server, edge, and browser environments**. + +### SDKs and Engines. + +| SDK/Engine | Status | +| ---------------------- | ------------------- | +| Rust Core Engine | Available (Publishing Pending) | +| Python SDK | Available | +| JavaScript Runtime SDK | WIP | +| JavaScript Browser SDK | WIP | +| Go SDK | Planned | + +### JavaScript Runtime Compatibility + +| Runtime | Status | +| ------------------ | --------| +| Node.js | WIP | +| Bun | WIP | +| Cloudflare Workers | WIP | +| AWS Lambda | WIP | +| Web Browsers | WIP | +| Deno | WIP | +| Fastly Compute@Edge | WIP | +| Vercel Edge Runtime | WIP | +| Netlify Edge Functions | WIP | + +### Native WebAssembly Runtime Targets -Actra supports multiple runtimes. +| Runtime | Status | +| -------- | ------- | +| Wasmtime | Planned | +| Wasmer | Planned | -| Runtime | Status | -| ------- | ------------ | -| Python | Available | -| Node.js | WIP | -| Rust | Core runtime | -| WASM | Planned | -| Go | Planned | --- diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 63a0c35..6fd2d44 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -43,7 +43,7 @@ pub struct JsEvaluationOutput { /// /// The compiled policy is stored internally. /// Evaluation operations are pure and deterministic. -#[wasm_bindgen] +#[wasm_bindgen (js_name = Actra)] pub struct Actra { compiled_policy: CompiledPolicy, } @@ -132,8 +132,11 @@ impl Actra { #[wasm_bindgen] pub fn evaluate(&self, input: JsValue) -> Result { - let js_input: JsEvaluationInput = - from_value(input)?; + let js_input: JsEvaluationInput = + from_value(input).map_err(|e| JsValue::from_str(&format!( + "Invalid evaluation input: {}", + e + )))?; let eval_input = EvaluationInput { action: js_input.action, diff --git a/core/src/ir.rs b/core/src/ir.rs index f05f423..8e91de9 100644 --- a/core/src/ir.rs +++ b/core/src/ir.rs @@ -99,6 +99,7 @@ pub enum ValueRef { /// /// These correspond to validated schema field types. #[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] pub enum ScalarValue { String(String), Number(f64), diff --git a/scripts/wasm_pack.sh b/scripts/wasm_pack.sh index eb22fbd..69eac1a 100644 --- a/scripts/wasm_pack.sh +++ b/scripts/wasm_pack.sh @@ -27,8 +27,8 @@ cargo install wasm-pack # 1. Browser build wasm-pack build bindings/wasm --target web --out-dir pkg/web --release -# 2. Node build -wasm-pack build bindings/wasm --target nodejs --out-dir pkg/all --release +# 2. Server build +wasm-pack build bindings/wasm --target nodejs --out-dir pkg/server --release # cargo install wasm-opt @@ -50,4 +50,9 @@ wasm-opt -Oz \ --strip-producers \ -o bindings/wasm/pkg/web/actra_wasm_bg.wasm \ bindings/wasm/pkg/web/actra_wasm_bg.wasm - \ No newline at end of file + +cd sdk/js +npm install +npm run build + +#Publish \ No newline at end of file diff --git a/sdk/go/sdk-blueprint.go b/sdk/go/sdk-blueprint.go new file mode 100644 index 0000000..79abf90 --- /dev/null +++ b/sdk/go/sdk-blueprint.go @@ -0,0 +1,117 @@ +// v1 +package actra + +import "time" + +// Types +type Action map[string]interface{} +type Actor map[string]interface{} +type Snapshot map[string]interface{} +type Decision map[string]interface{} +type Context interface{} +type EvaluationContext map[string]interface{} +type ActionInput struct { + Action Action + Actor Actor + Snapshot Snapshot +} + +// Resolver Types +type ActionBuilder func(actionType string, args []interface{}, kwargs map[string]interface{}, ctx Context) Action +type ActorResolver func(ctx Context) Actor +type SnapshotResolver func(ctx Context) Snapshot +type ActionResolver func(actionType string, args []interface{}, kwargs map[string]interface{}, ctx Context) Action +type ContextResolver func(args []interface{}, kwargs map[string]interface{}) Context +type ActionTypeResolver func(fn interface{}, args []interface{}, kwargs map[string]interface{}) string + +// Errors +type ActraError struct{ Msg string } +type ActraPolicyError struct { + ActraError + Decision Decision + Context EvaluationContext + MatchedRule string +} +type ActraSchemaError struct{ ActraError } + +// Decision Event +type DecisionEvent struct { + Action Action + Decision Decision + Context EvaluationContext + Timestamp time.Time + DurationMs float64 +} + +func (e *DecisionEvent) Effect() string { return e.Decision["effect"].(string) } +func (e *DecisionEvent) MatchedRule() string { return e.Decision["matched_rule"].(string) } +func (e *DecisionEvent) IsBlocked() bool { return e.Effect() == "block" } +func (e *DecisionEvent) ActionType() string { return e.Action["type"].(string) } + +// Policy +type Policy struct{} + +func (p *Policy) Evaluate(ctx ActionInput) Decision { return Decision{} } +func (p *Policy) EvaluateAction(action Action, actor Actor, snapshot Snapshot) Decision { + return Decision{} +} +func (p *Policy) Explain(ctx ActionInput) Decision { return Decision{} } +func (p *Policy) PolicyHash() string { return "" } +func (p *Policy) AssertEffect(ctx ActionInput, expected string) Decision { return Decision{} } + +// Actra Loader +type Actra struct{} + +func (a *Actra) FromStrings(schemaYaml, policyYaml string, governanceYaml ...string) *Policy { + return &Policy{} +} +func (a *Actra) FromFiles(schemaPath, policyPath string, governancePath ...string) *Policy { + return &Policy{} +} +func (a *Actra) FromDirectory(directory string) *Policy { return &Policy{} } +func (a *Actra) CompilerVersion() string { return "0.0.0" } + +// Runtime +type ActraRuntime struct { + Policy *Policy + ActorResolver ActorResolver + SnapshotResolver SnapshotResolver + ActionResolver ActionResolver + ContextResolver ContextResolver + ActionTypeResolver ActionTypeResolver +} + +func (r *ActraRuntime) SetActorResolver(fn ActorResolver) {} +func (r *ActraRuntime) SetSnapshotResolver(fn SnapshotResolver) {} +func (r *ActraRuntime) SetActionResolver(fn ActionResolver) {} +func (r *ActraRuntime) SetContextResolver(fn ContextResolver) {} +func (r *ActraRuntime) SetActionTypeResolver(fn ActionTypeResolver) {} +func (r *ActraRuntime) Allow(actionType string, ctx Context, fields map[string]interface{}) bool { + return true +} +func (r *ActraRuntime) Block(actionType string, ctx Context, fields map[string]interface{}) bool { + return false +} +func (r *ActraRuntime) Action(actionType string, fields map[string]interface{}) Action { + return Action{"type": actionType} +} +func (r *ActraRuntime) BuildAction(actionType string, args []interface{}, kwargs map[string]interface{}, ctx Context) Action { + return Action{"type": actionType} +} +func (r *ActraRuntime) BuildContext(action Action, ctx Context) EvaluationContext { + return EvaluationContext{} +} +func (r *ActraRuntime) Evaluate(action Action, ctx Context) Decision { return Decision{} } +func (r *ActraRuntime) Explain(action Action, ctx Context) Decision { return Decision{} } +func (r *ActraRuntime) Admit(actionType string, fields []string, builder ActionBuilder, fn func()) { + fn() +} +func (r *ActraRuntime) Audit(actionType string, fields []string, builder ActionBuilder, fn func()) { + fn() +} + +// Aliases +var LoadPolicyFromFile = Actra{}.FromFiles +var LoadPolicyFromString = Actra{}.FromStrings +var CompilerVersion = Actra{}.CompilerVersion +var Version = "0.0.0" diff --git a/sdk/js/common/package.json b/sdk/js/common/package.json new file mode 100644 index 0000000..bdeacc5 --- /dev/null +++ b/sdk/js/common/package.json @@ -0,0 +1,10 @@ +{ + "name": "@actra/common", + "version": "0.1.0", + "private": true, + "description": "Shared common utilities for Actra JavaScript SDKs", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "sideEffects": false +} \ No newline at end of file diff --git a/sdk/js/common/src/actra.ts b/sdk/js/common/src/actra.ts new file mode 100644 index 0000000..feb7664 --- /dev/null +++ b/sdk/js/common/src/actra.ts @@ -0,0 +1,87 @@ +//actra.ts +import { Policy } from "./policy" +import { getEngine, ensureEngineReady } from "./engine" +import { loadFile, loadPolicyDirectory } from "./loader" + +/* + Actra compiler facade. + + Responsible for compiling policies and returning Policy instances. + The underlying engine implementation is registered by the platform + adapter (server/browser). +*/ +export class Actra { + + private static get engine() { + return getEngine() + } + + /** + Compile policy from YAML strings + */ + static async fromStrings( + schemaYaml: string, + policyYaml: string, + governanceYaml?: string + ): Promise { + try { + await ensureEngineReady() + + const compiled = this.engine.compile( + schemaYaml, + policyYaml, + governanceYaml + ) + + return new Policy(compiled) + + } catch (err) { + throw new Error(`Actra compilation failed: ${err}`) + } + } + + /* + Compile policy from YAML files + */ + static async fromFiles( + schemaPath: string, + policyPath: string, + governancePath?: string + ): Promise { + + const schema = await loadFile(schemaPath) + const policy = await loadFile(policyPath) + + const governance = governancePath + ? await loadFile(governancePath) + : undefined + + return this.fromStrings(schema, policy, governance) + + } + + /* + Compile policy from directory + */ + static async fromDirectory( + directory: string + ): Promise { + + const files = await loadPolicyDirectory(directory) + + return this.fromStrings( + files.schema, + files.policy, + files.governance + ) + + } + + /* + Return Actra compiler version + */ + static async compilerVersion(): Promise { + return this.engine.compilerVersion() + + } +} diff --git a/sdk/js/common/src/engine.ts b/sdk/js/common/src/engine.ts new file mode 100644 index 0000000..80436a8 --- /dev/null +++ b/sdk/js/common/src/engine.ts @@ -0,0 +1,55 @@ +import { EvaluationInput, Decision } from "./types" + +let initPromise: Promise | null = null + +export function setInitPromise(promise: Promise) { + initPromise = promise +} + +export async function ensureEngineReady() { + if (initPromise) { + await initPromise + } +} + +export interface NativePolicy { + evaluate(input: EvaluationInput): Decision + policyHash(): string +} + +export interface NativeCompiler { + compile( + schema: string, + policy: string, + governance?: string + ): NativePolicy + + compilerVersion(): string +} + +let registeredEngine: NativeCompiler | null = null + +export function registerEngine(engineImpl: NativeCompiler) { + if (!engineImpl) { + throw new Error("Invalid Actra engine") + } + + if (registeredEngine === engineImpl) { + return // idempotent + } + + if (registeredEngine) { + throw new Error("Actra engine already initialized") + } + + registeredEngine = engineImpl +} + +export function getEngine(): NativeCompiler { + if (!registeredEngine) { + throw new Error( + "Actra engine not initialized. Call initializeWasmEngine() first." + ) + } + return registeredEngine +} \ No newline at end of file diff --git a/sdk/js/common/src/errors.ts b/sdk/js/common/src/errors.ts new file mode 100644 index 0000000..6bf0d84 --- /dev/null +++ b/sdk/js/common/src/errors.ts @@ -0,0 +1,16 @@ +export class ActraError extends Error { + constructor(message: string) { + super(message) + this.name = "ActraError" + } +} + +export class ActraPolicyError extends ActraError { + matchedRule?: string + + constructor(message: string, matchedRule?: string) { + super(message) + this.name = "ActraPolicyError" + this.matchedRule = matchedRule + } +} diff --git a/sdk/js/common/src/events.ts b/sdk/js/common/src/events.ts new file mode 100644 index 0000000..8e5d9e7 --- /dev/null +++ b/sdk/js/common/src/events.ts @@ -0,0 +1,28 @@ +import { DecisionEvent } from "./types" + +export type DecisionObserver = (event: DecisionEvent) => void + +export class DecisionEmitter { + + private observer?: DecisionObserver + + setDecisionObserver(observer?: DecisionObserver) { + this.observer = observer + } + + hasDecisionObserver(): boolean { + return !!this.observer + } + emit(event: DecisionEvent) { + + if (!this.observer) return + + try { + this.observer(event) + } catch { + // Observability should never break runtime + } + } +} + + diff --git a/sdk/js/common/src/index.ts b/sdk/js/common/src/index.ts new file mode 100644 index 0000000..af99c9b --- /dev/null +++ b/sdk/js/common/src/index.ts @@ -0,0 +1,19 @@ +export * from "./actra" +export { Policy } from "./policy" +export { ActraRuntime } from "./runtime" +export * from "./types" +export * from "./engine" + +export { ActraError, ActraPolicyError } from "./errors" + +export type { + Action, + Actor, + Snapshot, + Decision, + DecisionEvent, + EvaluationInput, + Scalar +} from "./types" + +export type { DecisionObserver } from "./events" diff --git a/sdk/js/common/src/loader.ts b/sdk/js/common/src/loader.ts new file mode 100644 index 0000000..346196f --- /dev/null +++ b/sdk/js/common/src/loader.ts @@ -0,0 +1,49 @@ +import fs from "fs/promises" +import path from "path" + +export async function loadFile(filePath: string): Promise { + return fs.readFile(filePath, "utf8") +} + +export async function loadPolicyDirectory( + dir: string +): Promise<{ + schema: string + policy: string + governance?: string +}> { + + const base = path.resolve(dir) + + let schema: string + let policy: string + let governance: string | undefined + + try { + schema = await loadFile(path.join(base, "schema.yaml")) + } catch (err: any) { + throw new Error( + `Actra policy directory missing schema.yaml: ${err?.message || err}` + ) + } + + try { + policy = await loadFile(path.join(base, "policy.yaml")) + } catch (err: any) { + throw new Error( + `Actra policy directory missing policy.yaml: ${err?.message || err}` + ) + } + + try { + governance = await loadFile(path.join(base, "governance.yaml")) + } catch { + governance = undefined + } + + return { + schema, + policy, + governance + } +} diff --git a/sdk/js/common/src/policy.ts b/sdk/js/common/src/policy.ts new file mode 100644 index 0000000..4419e5f --- /dev/null +++ b/sdk/js/common/src/policy.ts @@ -0,0 +1,41 @@ +import { EvaluationInput, Decision } from "./types" +import { validateEvaluationInput } from "./validators" +import { NativePolicy } from "./engine" + +export class Policy { + + private nativePolicy: NativePolicy + + constructor(policyImpl: NativePolicy) { + + if (!policyImpl) { + throw new Error("Invalid native policy instance") + } + + this.nativePolicy = policyImpl + } + + evaluate(input: EvaluationInput): Decision { + + validateEvaluationInput(input) + + try { + return this.nativePolicy.evaluate(input) + } catch (err) { + throw new Error( + `Actra policy evaluation failed: ${err}` + ) + } + } + + policyHash(): string { + + try { + return this.nativePolicy.policyHash() + } catch (err) { + throw new Error( + `Failed to retrieve policy hash: ${err}` + ) + } + } +} diff --git a/sdk/js/common/src/runtime.ts b/sdk/js/common/src/runtime.ts new file mode 100644 index 0000000..5228af4 --- /dev/null +++ b/sdk/js/common/src/runtime.ts @@ -0,0 +1,213 @@ +import { Policy } from "./policy" +import { + Actor, + Snapshot, + Action, + Decision +} from "./types" +import { ActraError, ActraPolicyError } from "./errors" +import { DecisionEmitter, DecisionObserver } from "./events" + +function buildAction( + actionName: string, + args: any[], + builder?: (args: any[], ctx?: any) => Record, + ctx?: any +): Action { + + const base: Action = { action: actionName } + + if (!builder) { + return base + } + + let mapped: Record + + try { + mapped = builder(args, ctx) + } catch (err) { + throw new ActraError(`Action builder failed: ${serializeError(err)}`) + } + return { + ...base, + ...mapped + } +} + +function serializeError(err: unknown): string { + if (err instanceof Error) { + return `${err.name}: ${err.message}` + } + + try { + return JSON.stringify(err, null, 2) + } catch { + return String(err) + } +} + +export class ActraRuntime { + + private policy: Policy + private actorResolver?: (ctx: any) => Actor + private snapshotResolver?: (ctx: any) => Snapshot + private events = new DecisionEmitter() + + private resolveSafe( + resolver: ((ctx: any) => T) | undefined, + ctx: any, + label: string + ): T { + + if (!resolver) { + return {} as T + } + + try { + return resolver(ctx) || {} as T + } catch (err) { + throw new ActraError(`Resolver failed: ${serializeError(err)}`) + } + } + + constructor(policy: Policy) { + this.policy = policy + } + + setActorResolver(resolver: (ctx: any) => Actor) { + this.actorResolver = resolver + } + + setSnapshotResolver(resolver: (ctx: any) => Snapshot) { + this.snapshotResolver = resolver + } + + setDecisionObserver(observer: DecisionObserver) { + this.events.setDecisionObserver(observer) + } + + evaluate(action: Action, ctx?: any): Decision { + + const actor = this.resolveSafe(this.actorResolver, ctx, "Actor") + const snapshot = this.resolveSafe(this.snapshotResolver, ctx, "Snapshot") + + const start = Date.now() + let decision: Decision + try { + decision = this.policy.evaluate({ + action, + actor, + snapshot + }) + } catch (err) { + throw new ActraError(`Policy evaluation failed: ${serializeError(err)}`) + } + const end = Date.now() + const duration = end - start + + try { + if (this.events.hasDecisionObserver()) { + this.events.emit({ + action, + decision, + context: { action, actor, snapshot }, + timestamp: end, + durationMs: duration + }) + } + } catch { + // observability should never break execution + } + + return decision + } + + allow( + actionName: string, + args?: Record, + ctx?: any + ): boolean { + + const decision = this.evaluate( + { action: actionName, ...(args || {}) }, + ctx + ) + + return decision.effect === "allow" + } + + admit( + actionName: string, + fn: (...args: any[]) => any, + builder?: (args: any[], ctx?: any) => Record + ): Function { + + if (!actionName || typeof actionName !== "string") { + throw new ActraError("Invalid action name") + } + + const runtime = this + + return async function (this: any, ...args: any[]) { + + const action = buildAction(actionName, args, builder, this) + + const decision = runtime.evaluate(action, this) + + if (decision.effect !== "allow") { + throw new ActraPolicyError( + "Action blocked", + decision.matched_rule + ) + } + + return fn.apply(this, args) + } + } + + audit( + actionName: string, + fn: (...args: any[]) => any, + builder?: (args: any[], ctx?: any) => Record + ): Function { + + if (!actionName || typeof actionName !== "string") { + throw new ActraError("Invalid action name") + } + + const runtime = this + + return async function (this: any, ...args: any[]) { + + const action = buildAction(actionName, args, builder, this) + + runtime.evaluate(action, this) + + return fn.apply(this, args) + } + } +} + +//Example +//Simple Case +//runtime.admit("transfer", transferFunds) + +//Argument mapping +//runtime.admit( +// "transfer", +// transferFunds, +// (args) => ({ +// userId: args[0], +// amount: args[1] +// }) +//) + +//context-aware mapping +//runtime.admit( +// "transfer", +// transferFunds, +// (args, ctx) => ({ +// userId: ctx.user.id, +// amount: args[1] +// }) +//) \ No newline at end of file diff --git a/sdk/js/common/src/types.ts b/sdk/js/common/src/types.ts new file mode 100644 index 0000000..f5d8a9d --- /dev/null +++ b/sdk/js/common/src/types.ts @@ -0,0 +1,40 @@ +export type Scalar = + | string + | number + | boolean + | null + +export type DecisionEffect = + | "allow" + | "block" + | "require_approval" + +export type Action = { + action: string +} & Record + +export type Actor = Record + +export type Snapshot = Record + +export interface EvaluationInput { + action: Action + actor: Actor + snapshot: Snapshot +} + +export interface Decision { + effect: DecisionEffect + matched_rule?: string + + // Future extension point + // required_actions?: Record[] +} + +export interface DecisionEvent { + action: Action + decision: Decision + context: EvaluationInput + timestamp: number + durationMs: number +} diff --git a/sdk/js/common/src/validators.ts b/sdk/js/common/src/validators.ts new file mode 100644 index 0000000..621e112 --- /dev/null +++ b/sdk/js/common/src/validators.ts @@ -0,0 +1,26 @@ +import { EvaluationInput } from "./types" + +function isObject(value: unknown): boolean { + return typeof value === "object" && value !== null +} + +export function validateEvaluationInput( + input: EvaluationInput +) { + + if (!input) { + throw new Error("evaluation input missing") + } + + if (!isObject(input.action)) { + throw new Error("action must be an object") + } + + if (!isObject(input.actor)) { + throw new Error("actor must be an object") + } + + if (!isObject(input.snapshot)) { + throw new Error("snapshot must be an object") + } +} diff --git a/sdk/js/package.json b/sdk/js/package.json new file mode 100644 index 0000000..2181943 --- /dev/null +++ b/sdk/js/package.json @@ -0,0 +1,15 @@ +{ + "private": true, + "workspaces": [ + "common", + "server", + "browser" + ], + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "scripts": { + "build": "npm run build -w runtime && npm run build -w web" + } +} \ No newline at end of file diff --git a/sdk/js/sdk-blueprint.ts b/sdk/js/sdk-blueprint.ts new file mode 100644 index 0000000..f7b75f0 --- /dev/null +++ b/sdk/js/sdk-blueprint.ts @@ -0,0 +1,120 @@ +// v1 +// Types +export type Action = Record; +export type Actor = Record; +export type Snapshot = Record; +export type Decision = Record; +export type Context = any; +export type EvaluationContext = Record; +export type ActionInput = { action: Action; actor: Actor; snapshot: Snapshot }; + +// Resolver Types +export type ActionBuilder = (actionType: string, args: any[], kwargs: Record, ctx?: Context) => Action; +export type ActorResolver = (ctx?: Context) => Actor; +export type SnapshotResolver = (ctx?: Context) => Snapshot; +export type ActionResolver = (actionType: string, args: any[], kwargs: Record, ctx?: Context) => Action; +export type ContextResolver = (args: any[], kwargs: Record) => Context; +export type ActionTypeResolver = (func: Function, args: any[], kwargs: Record) => string; + +// Errors +export class ActraError extends Error {} +export class ActraPolicyError extends ActraError { + decision: Decision; + context: EvaluationContext; + matchedRule?: string; + constructor(actionType: string, decision: Decision, context: EvaluationContext) { + super(`Actra policy blocked action '${actionType}'`); + this.decision = decision; + this.context = context; + this.matchedRule = decision["matched_rule"]; + } + toDict(): Record { + return { action: this.decision["action"], decision: this.decision, context: this.context }; + } +} +export class ActraSchemaError extends ActraError {} + +// Decision Event +export class DecisionEvent { + action: Action; + decision: Decision; + context: EvaluationContext; + timestamp: Date; + durationMs: number; + + constructor(action: Action, decision: Decision, context: EvaluationContext, durationMs = 0) { + this.action = action; + this.decision = decision; + this.context = context; + this.timestamp = new Date(); + this.durationMs = durationMs; + } + + get effect(): string { + return this.decision["effect"]; + } + + get matched_rule(): string | undefined { + return this.decision["matched_rule"]; + } + + get is_blocked(): boolean { + return this.effect === "block"; + } + + get action_type(): string { + return this.action["type"] || "unknown"; + } +} + +// Policy +export class Policy { + evaluate(context: ActionInput): Decision { return {}; } + evaluateAction(action: Action, actor: Actor, snapshot: Snapshot): Decision { return {}; } + explain(context: ActionInput): Decision { return {}; } + policyHash(): string { return ""; } + assertEffect(context: ActionInput, expected: string): Decision { return {}; } +} + +// Actra Loader +export class Actra { + static fromStrings(schemaYaml: string, policyYaml: string, governanceYaml?: string): Policy { return new Policy(); } + static fromFiles(schemaPath: string, policyPath: string, governancePath?: string): Policy { return new Policy(); } + static fromDirectory(directory: string): Policy { return new Policy(); } + static compilerVersion(): string { return "0.0.0"; } +} + +// Runtime +export class ActraRuntime { + policy: Policy; + setActorResolver(fn: ActorResolver): void {} + setSnapshotResolver(fn: SnapshotResolver): void {} + setActionResolver(fn: ActionResolver): void {} + setContextResolver(fn: ContextResolver): void {} + setActionTypeResolver(fn: ActionTypeResolver): void {} + setDecisionObserver(fn: (event: DecisionEvent) => void): void {} + + resolveActor(ctx?: Context): Actor { return {}; } + resolveSnapshot(ctx?: Context): Snapshot { return {}; } + resolveContext(args: any[], kwargs: Record): Context | undefined { return undefined; } + resolveActionType(func: Function, args: any[], kwargs: Record, actionType?: string): string { return ""; } + + allow(actionType: string, ctx?: Context, fields?: Record): boolean { return true; } + block(actionType: string, ctx?: Context, fields?: Record): boolean { return false; } + action(actionType: string, fields?: Record): Action { return { type: actionType }; } + buildAction(actionType: string, args: any[], kwargs: Record, ctx?: Context, fields?: string[]): Action { return { type: actionType }; } + buildContext(action: Action, ctx?: Context): EvaluationContext { return {}; } + + evaluate(action: Action, ctx?: Context): Decision { return {}; } + explain(action: Action, ctx?: Context): Decision { return {}; } + explainCall(func: Function, args: any[], actionType?: string, ctx?: Context, kwargs?: Record): Decision { return {}; } + + admit(actionType?: string, fields?: string[], actionBuilder?: ActionBuilder): Function { return (f: Function) => f; } + audit(actionType?: string, fields?: string[], actionBuilder?: ActionBuilder): Function { return (f: Function) => f; } +} + +// Aliases +export const loadPolicyFromFile = Actra.fromFiles; +export const loadPolicyFromString = Actra.fromStrings; +export const compiler_version = Actra.compilerVersion; +export const __version__ = "0.0.0"; \ No newline at end of file diff --git a/sdk/js/server/package.json b/sdk/js/server/package.json new file mode 100644 index 0000000..5d3ac92 --- /dev/null +++ b/sdk/js/server/package.json @@ -0,0 +1,41 @@ +{ + "name": "@actra/server", + "version": "0.1.0", + "description": "Actra JavaScript SDK for server and edge runtimes", + "type": "module", + "dependencies": { + "@actra/common": "file:../common", + "fs": "^0.0.1-security" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "pkg" + ], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "sideEffects": false, + "engines": { + "node": ">=18" + }, + "keywords": [ + "actra", + "policy", + "authorization", + "admission-control", + "ai-safety" + ], + "license": "Apache-2.0", + "scripts": { + "build": "tsup src/index.ts --format esm --dts", + "test": "vitest" + }, + "devDependencies": { + "vitest": "^4.1.0" + } +} diff --git a/sdk/js/server/src/engine.ts b/sdk/js/server/src/engine.ts new file mode 100644 index 0000000..29c0a97 --- /dev/null +++ b/sdk/js/server/src/engine.ts @@ -0,0 +1,50 @@ +import { registerEngine, NativeCompiler, NativePolicy } from "@actra/common" +import { EvaluationInput, Decision } from "@actra/common" +import { loadActraWasm } from "./wasm-loader" + +let initPromise: Promise | null = null + +export function initializeWasmEngine(): Promise { + + if (initPromise) { + return initPromise + } + + initPromise = (async () => { + const wasm = await loadActraWasm() + if (!wasm?.Actra) { + throw new Error("Invalid Actra WASM module") + } + const compiler: NativeCompiler = { + compile( + schema: string, + policy: string, + governance?: string + ): NativePolicy { + const instance = new wasm.Actra(schema, policy, governance) + + return { + evaluate(input: EvaluationInput): Decision { + try{ + return instance.evaluate(input) + } catch (err) { + throw new Error (`Actra Evaluation failed: ${err}`) + } + }, + + policyHash(): string { + return instance.policy_hash() + } + } + }, + + compilerVersion(): string { + return wasm.Actra.compiler_version() + } + + } + registerEngine(compiler) + })() + + return initPromise +} \ No newline at end of file diff --git a/sdk/js/server/src/index.ts b/sdk/js/server/src/index.ts new file mode 100644 index 0000000..565b056 --- /dev/null +++ b/sdk/js/server/src/index.ts @@ -0,0 +1,18 @@ +import { initializeWasmEngine } from "./engine" +import { setInitPromise } from "@actra/common" + +// Start initialization immediately +const ready = initializeWasmEngine() + +// Register it with core so Actra can await it internally +setInitPromise(ready) + +// Optional: surface errors (non-blocking) +ready.catch(err => { + console.error("Actra engine initialization failed", err) +}) + +// Optional export (advanced usage) +export const actraReady = ready + +export * from "@actra/common" \ No newline at end of file diff --git a/sdk/js/server/src/wasm-loader.ts b/sdk/js/server/src/wasm-loader.ts new file mode 100644 index 0000000..f84fc66 --- /dev/null +++ b/sdk/js/server/src/wasm-loader.ts @@ -0,0 +1,9 @@ +let wasmModule: any; + +export async function loadActraWasm() { + if (!wasmModule) { + wasmModule = await import("../pkg/server/actra_wasm.js"); + } + + return wasmModule; +} diff --git a/sdk/js/server/tsconfig.json b/sdk/js/server/tsconfig.json new file mode 100644 index 0000000..2def8b4 --- /dev/null +++ b/sdk/js/server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"] +} \ No newline at end of file diff --git a/sdk/js/server/tsup.config.ts b/sdk/js/server/tsup.config.ts new file mode 100644 index 0000000..c1cfef3 --- /dev/null +++ b/sdk/js/server/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + splitting: false, + sourcemap: false, + clean: true, + target: "es2022", + platform: "neutral", + external: [] +}) \ No newline at end of file diff --git a/sdk/js/tsconfig.json b/sdk/js/tsconfig.json new file mode 100644 index 0000000..b1e0acd --- /dev/null +++ b/sdk/js/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@actra/common": ["common/src"] + }, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "declaration": true, + "skipLibCheck": true, + "isolatedModules": true + } +} \ No newline at end of file diff --git a/sdk/python/actra/blueprint.py b/sdk/python/actra/blueprint.py new file mode 100644 index 0000000..d1de659 --- /dev/null +++ b/sdk/python/actra/blueprint.py @@ -0,0 +1,120 @@ +# Last Updated v0.5.2 +from typing import Any, Dict, Callable, List, Optional, Tuple + +# ---------------------- +# Types +# ---------------------- +Action = Dict[str, Any] +Actor = Dict[str, Any] +Snapshot = Dict[str, Any] +Decision = Dict[str, Any] +Context = Any +EvaluationContext = Dict[str, Any] +ActionInput = Dict[str, Any] # {"action": Action, "actor": Actor, "snapshot": Snapshot} + +# Resolver Types +ActionBuilder = Callable[[str, Tuple[Any, ...], Dict[str, Any], Context], Action] +ActorResolver = Callable[[Context], Actor] +SnapshotResolver = Callable[[Context], Snapshot] +ActionResolver = Callable[[str, Tuple[Any, ...], Dict[str, Any], Context], Action] +ContextResolver = Callable[[Tuple, Dict[str, Any]], Context] +ActionTypeResolver = Callable[[Callable, Tuple, Dict[str, Any]], str] + +# ---------------------- +# Errors +# ---------------------- +class ActraError(Exception): + pass + +class ActraPolicyError(ActraError): + decision: Decision + context: EvaluationContext + matched_rule: Optional[str] + +class ActraSchemaError(ActraError): + pass + +# ---------------------- +# Decision Event +# ---------------------- +class DecisionEvent: + action: Action + decision: Decision + context: EvaluationContext + timestamp: Any + duration_ms: float + + # Properties + @property + def effect(self) -> str: ... + + @property + def matched_rule(self) -> Optional[str]: ... + + @property + def is_blocked(self) -> bool: ... + + @property + def action_type(self) -> str: ... + +# ---------------------- +# Policy +# ---------------------- +class Policy: #-- + def evaluate(self, context: ActionInput) -> Decision: ... + def evaluate_action(self, action: Action, actor: Actor, snapshot: Snapshot) -> Decision: ... + def explain(self, context: ActionInput) -> Decision: ... + def policy_hash(self) -> str: ... + def assert_effect(self, context: ActionInput, expected: str) -> Decision: ... + +# ---------------------- +# Actra Loader +# ---------------------- +class Actra: #-- + @staticmethod + def from_strings(schema_yaml: str, policy_yaml: str, governance_yaml: Optional[str] = None) -> Policy: ... + + @staticmethod + def from_files(schema_path: str, policy_path: str, governance_path: Optional[str] = None) -> Policy: ... + + @staticmethod + def from_directory(directory: str) -> Policy: ... + + @staticmethod + def compiler_version() -> str: ... + +# ---------------------- +# Actra Runtime +# ---------------------- +class ActraRuntime: + policy: Policy + + # Resolver registration + def set_actor_resolver(self, fn: ActorResolver) -> None: ... + def set_snapshot_resolver(self, fn: SnapshotResolver) -> None: ... + def set_action_resolver(self, fn: ActionResolver) -> None: ... + def set_context_resolver(self, fn: ContextResolver) -> None: ... + def set_action_type_resolver(self, fn: ActionTypeResolver) -> None: ... + def set_decision_observer(self, fn: Callable[[DecisionEvent], None]) -> None: ... + + # Context resolution + def resolve_actor(self, ctx: Context = None) -> Actor: ... + def resolve_snapshot(self, ctx: Context = None) -> Snapshot: ... + def resolve_context(self, args: Tuple, kwargs: Dict[str, Any]) -> Optional[Context]: ... + def resolve_action_type(self, func: Callable, args: Tuple, kwargs: Dict[str, Any], action_type: Optional[str]) -> str: ... + + # Action helpers + def allow(self, action_type: str, ctx: Context = None, **fields) -> bool: ... + def block(self, action_type: str, ctx: Context = None, **fields) -> bool: ... + def action(self, action_type: str, **fields) -> Action: ... + def build_action(self, action_type: str, args: Tuple, kwargs: Dict[str, Any], ctx: Context = None, func: Optional[Callable] = None, fields: Optional[List[str]] = None, action_builder: Optional[ActionBuilder] = None) -> Action: ... + def build_context(self, action: Action, ctx: Context = None) -> EvaluationContext: ... + + # Policy evaluation + def evaluate(self, action: Action, ctx: Context = None) -> Decision: ... + def explain(self, action: Action, ctx: Context = None) -> Decision: ... + def explain_call(self, func: Callable, *args, action_type: Optional[str] = None, ctx: Optional[Context] = None, **kwargs) -> Decision: ... + + # Decorators + def admit(self, action_type: Optional[str] = None, fields: Optional[List[str]] = None, action_builder: Optional[ActionBuilder] = None) -> Callable: ... + def audit(self, action_type: Optional[str] = None, fields: Optional[List[str]] = None, action_builder: Optional[ActionBuilder] = None) -> Callable: ... \ No newline at end of file diff --git a/sdk/python/actra/events.py b/sdk/python/actra/events.py index 403063b..0ee644a 100644 --- a/sdk/python/actra/events.py +++ b/sdk/python/actra/events.py @@ -40,7 +40,7 @@ class DecisionEvent: @property def effect(self) -> str: - """Return policy effect (`allow` or `block`)""" + """Return policy effect (`allow`, `block` or `require_approval`)""" return self.decision.get("effect") @property @@ -56,4 +56,5 @@ def is_blocked(self) -> bool: @property def action_type(self) -> str: """Return the evaluated action type.""" - return self.action.get("type", "unknown") \ No newline at end of file + return self.action.get("type", "unknown") + \ No newline at end of file diff --git a/sdk/python/actra/runtime.py b/sdk/python/actra/runtime.py index 36ff9ed..7ef8ef0 100644 --- a/sdk/python/actra/runtime.py +++ b/sdk/python/actra/runtime.py @@ -96,6 +96,14 @@ def observer(event): runtime.set_decision_observer(observer) """ self._decision_observer = fn + + def has_decision_observer(self) -> bool: + """ + Returns True if a decision observer is registered. + + Useful to avoid unnecessary event construction in high-throughput scenarios. + """ + return self._decision_observer is not None def set_actor_resolver(self, fn: ActorResolver) -> None: """ @@ -737,7 +745,8 @@ def evaluate(self, action: Action, ctx: Context = None) -> Decision: duration_ms = (time.perf_counter() - start) * 1000 - self._emit_decision_event(decision, action, context, duration_ms) + if self.has_decision_observer: + self._emit_decision_event(decision, action, context, duration_ms) return decision