Root cause
Injection & unsafe execution
The new TypeScript renderer created a default Nunjucks runtime and rendered templates against prepared host values without a sandbox boundary.
How AI contributed: New attack surfacePublished Jul 24, 2026 in
microsoft/promptyAll 3 findings in this repository
Summary The TypeScript Nunjucks renderer evaluated untrusted .prompty template bodies with unrestricted JavaScript member access. An attacker-controlled template could traverse constructor and prototype properties to execute JavaScript in the host Node.js process. Affected packages · npm @prompty/core versions <= 0.1.4 · npm @prompty/core versions <= 2.0.0-beta.4 Impact Applications that render untrusted, community-supplied, cloned, or LLM-generated .prompty files with the TypeScript runtime could allow attacker-controlled code execution with the privileges of the Node.js host process. Remediation Upgrade to @prompty/core 2.0.0-beta.5 or later. The patched renderer sanitizes render inputs to own-data-only values, rejects constructor/prototype member traversal, and disallows template function calls. Ordinary interpolation, conditionals, loops, and own nested data properties remain supported. Fix details The fix is merged in PR #404 and includes regression coverage for default Nunjucks rendering, explicit renderer usage, unsafe member lookups, and attempted template function calls.
The Copilot-coauthored TypeScript v2 runtime created an unrestricted Nunjucks Environment and rendered attacker-controlled templates with host objects. The fix blocks constructor/prototype traversal, function calls, and non-own input data. The advisory has no CVE.
Root cause
The new TypeScript renderer created a default Nunjucks runtime and rendered templates against prepared host values without a sandbox boundary.
Fix
The direct PR member copies only safe own data, rejects constructor and prototype access, blocks calls, and routes the real sink through those guards.
AI-assisted fix: GitHub Copilot · Seth Juarez
Case facts
@@ -0,0 +1,30 @@+/**+ * Nunjucks renderer — Jinja2-compatible template rendering for TypeScript.+ *+ * Nunjucks is the standard Jinja2-compatible engine for Node.js.+ * This renderer replaces thread-kind inputs with nonce markers+ * before rendering.+ *+ * @module+ */++import nunjucks from "nunjucks";+import type { PromptAgent } from "agentschema";+import type { Renderer } from "../core/interfaces.js";+import { prepareRenderInputs } from "./common.js";++const env = new nunjucks.Environment(null, {+ autoescape: false,+ throwOnUndefined: false,+});++export class NunjucksRenderer implements Renderer {+ async render(+ agent: PromptAgent,+ template: string,+ inputs: Record<string, unknown>,+ ): Promise<string> {+ const [modified] = prepareRenderInputs(agent, inputs);+ return env.renderString(template, modified);+ }+}@@ -13,11 +13,91 @@ 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,@@ -25,6 +105,6 @@ export class NunjucksRenderer implements Renderer { inputs: Record<string, unknown>, ): Promise<string> { const [modified] = prepareRenderInputs(agent, inputs);- return env.renderString(template, modified);+ return renderSafely(template, sanitizeInputs(modified)); } }AI-assisted change 6053a07104b64c2f032ecb0b30f19d83e258a087861ac41e91817032fb76a18d · Fix 12d3e783e751cca86313a05b4f8fcc4c5a18f8e928453276da4bd0b64b44e233