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
7 changes: 5 additions & 2 deletions runtime/typescript/packages/anthropic/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prompty/anthropic",
"version": "2.0.0-beta.4",
"version": "2.0.0-beta.5",
"description": "Anthropic provider for Prompty — executor and processor for Anthropic Messages API",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -56,11 +56,14 @@
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@prompty/core": "^2.0.0-beta.4",
"@prompty/core": "^2.0.0-beta.5",
"@types/node": "^20.11.0",
"dotenv": "^16.4.0",
"tsup": "^8.4.0",
"typescript": "^5.7.0",
"vitest": "^4.1.8"
},
"dependencies": {
"@prompty/core": "^2.0.0-beta.4"
}
}
6 changes: 3 additions & 3 deletions runtime/typescript/packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prompty/core",
"version": "2.0.0-beta.4",
"version": "2.0.0-beta.5",
"description": "Prompty core runtime — load, render, parse, and trace .prompty files",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -60,8 +60,8 @@
},
"devDependencies": {
"@opentelemetry/api": "^1.9.0",
"@prompty/anthropic": "^2.0.0-beta.4",
"@prompty/openai": "^2.0.0-beta.4",
"@prompty/anthropic": "^2.0.0-beta.5",
"@prompty/openai": "^2.0.0-beta.5",
"@types/mustache": "^4.2.5",
"@types/node": "^20.11.0",
"@types/nunjucks": "^3.2.6",
Expand Down
82 changes: 81 additions & 1 deletion runtime/typescript/packages/core/src/renderers/nunjucks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,98 @@ import type { Prompty } from "../model/agent/prompty.js";
import type { Renderer } from "../core/interfaces.js";
import { prepareRenderInputs } from "./common.js";

type NunjucksRuntime = {
memberLookup: (object: unknown, property: unknown) => unknown;
callWrap: (callable: unknown, name: string, context: unknown, args: unknown[]) => unknown;
};

const UNSAFE_PROPERTIES = new Set(["__proto__", "constructor", "prototype"]);

const env = new nunjucks.Environment(null, {
autoescape: false,
throwOnUndefined: false,
});

function safeMemberLookup(object: unknown, property: unknown): unknown {
if (typeof property === "string" && UNSAFE_PROPERTIES.has(property)) {
throw new Error(`Unsafe template member access: ${property}`);
}

if (
(typeof property !== "string" && typeof property !== "number") ||
object === null ||
typeof object !== "object"
) {
return undefined;
}

const descriptor = Object.getOwnPropertyDescriptor(object, property);
return descriptor !== undefined && "value" in descriptor ? descriptor.value : undefined;
}

function safeCallWrap(_callable: unknown, name: string, _context: unknown, _args: unknown[]): never {
throw new Error(`Template function calls are not allowed: ${name}`);
}

function sanitizeValue(value: unknown, seen = new WeakMap<object, unknown>()): unknown {
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}

if (typeof value !== "object") {
return undefined;
}

const existing = seen.get(value);
if (existing !== undefined) {
return existing;
}

if (Array.isArray(value)) {
const result: unknown[] = [];
seen.set(value, result);
for (const item of value) {
result.push(sanitizeValue(item, seen));
}
return result;
}

const result = Object.create(null) as Record<string, unknown>;
seen.set(value, result);
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
if (!UNSAFE_PROPERTIES.has(key) && "value" in descriptor) {
result[key] = sanitizeValue(descriptor.value, seen);
}
}
return result;
}

function sanitizeInputs(inputs: Record<string, unknown>): Record<string, unknown> {
return sanitizeValue(inputs) as Record<string, unknown>;
}

function renderSafely(template: string, inputs: Record<string, unknown>): string {
const runtime = nunjucks.runtime as unknown as NunjucksRuntime;
const memberLookup = runtime.memberLookup;
const callWrap = runtime.callWrap;
runtime.memberLookup = safeMemberLookup;
runtime.callWrap = safeCallWrap;

try {
return env.renderString(template, inputs);
} finally {
runtime.memberLookup = memberLookup;
runtime.callWrap = callWrap;
}
}

export class NunjucksRenderer implements Renderer {
async render(
agent: Prompty,
template: string,
inputs: Record<string, unknown>,
): Promise<string> {
const [modified] = prepareRenderInputs(agent, inputs);
return env.renderString(template, modified);
return renderSafely(template, sanitizeInputs(modified));
}
}
26 changes: 25 additions & 1 deletion runtime/typescript/packages/core/tests/renderers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { NunjucksRenderer } from "../src/renderers/nunjucks.js";
import { MustacheRenderer } from "../src/renderers/mustache.js";
import { Prompty } from "@prompty/core";
Expand Down Expand Up @@ -29,6 +29,30 @@ describe("NunjucksRenderer", () => {
const result = await renderer.render(agent, template, { items: ["a", "b", "c"] });
expect(result.trim()).toBe("a b c");
});

it("renders own nested data properties", async () => {
const result = await renderer.render(agent, "{{ customer.name }}", {
customer: { name: "Ada" },
});
expect(result).toBe("Ada");
});

it.each(["{{ value.constructor }}", "{{ value.__proto__ }}", "{{ value.prototype }}"])(
"rejects unsafe member access: %s",
async (template) => {
await expect(renderer.render(agent, template, { value: "test" })).rejects.toThrow(
"Unsafe template member access",
);
},
);

it("rejects template function calls without invoking the input function", async () => {
const callback = vi.fn();
await expect(renderer.render(agent, "{{ callback() }}", { callback })).rejects.toThrow(
"Template function calls are not allowed",
);
expect(callback).not.toHaveBeenCalled();
});
});

describe("MustacheRenderer", () => {
Expand Down
8 changes: 5 additions & 3 deletions runtime/typescript/packages/foundry/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prompty/foundry",
"version": "2.0.0-beta.4",
"version": "2.0.0-beta.5",
"description": "Microsoft Foundry provider for Prompty — executor and processor for Azure AI Foundry",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -52,11 +52,13 @@
"dependencies": {
"@azure/ai-projects": "^2.0.1",
"@azure/identity": "^4.13.1",
"@prompty/core": "^2.0.0-beta.4",
"@prompty/openai": "^2.0.0-beta.4",
"openai": "^4.80.0"
},
"devDependencies": {
"@prompty/core": "^2.0.0-beta.4",
"@prompty/openai": "^2.0.0-beta.4",
"@prompty/core": "^2.0.0-beta.5",
"@prompty/openai": "^2.0.0-beta.5",
"@types/node": "^20.11.0",
"dotenv": "^16.4.0",
"tsup": "^8.4.0",
Expand Down
5 changes: 3 additions & 2 deletions runtime/typescript/packages/openai/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@prompty/openai",
"version": "2.0.0-beta.4",
"version": "2.0.0-beta.5",
"description": "OpenAI provider for Prompty — executor and processor for OpenAI APIs",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -48,10 +48,11 @@
"@prompty/core": "^2.0.0-beta.4"
},
"dependencies": {
"@prompty/core": "^2.0.0-beta.4",
"openai": "^4.80.0"
},
"devDependencies": {
"@prompty/core": "^2.0.0-beta.4",
"@prompty/core": "^2.0.0-beta.5",
"@types/node": "^20.11.0",
"dotenv": "^16.4.0",
"tsup": "^8.4.0",
Expand Down
Loading