Protecting Data & Preventing Abuse

Protecting Sensitive Data from AI Agents

⏱ 12 min

Permission checks decide who can call an ability. This lesson is about a different, equally important question: what that ability actually hands back, even to someone correctly permitted to call it. An AI agent that can legitimately look up a customer order doesn’t necessarily need that customer’s billing email, phone number, and home address in the response. The best defense against leaking sensitive data to an AI agent is never producing it in the first place.

What you'll learn in this lesson
Designing output_schema around need, not convenience
Declaring only the fields an agent workflow actually requires.
Redacting inside execute_callback, not trusting the schema alone
The schema documents intent, the code has to enforce it.
A concrete PII case study
A WooCommerce order lookup ability, done wrong and done right.
Why "the model probably will not misuse it" is not a control
Data an agent can see can end up in a chat transcript, a log, or a downstream summary.
Prerequisites

Understanding of input_schema and output_schema from Course 1, and Lesson 5’s permission-gated tools.

Step 1: Design output_schema around what the workflow needs, not what’s available

It’s common to build an ability by wrapping an existing internal function and returning whatever that function already returns. That’s convenient and frequently wrong for anything customer- or user-facing. Start from the actual agent workflow and work backward: what does “check whether an order shipped” actually require the model to see?

// File: my-plugin.php
'output_schema' => array(
	'type'       => 'object',
	'properties' => array(
		'order_id'     => array( 'type' => 'integer' ),
		'status'       => array( 'type' => 'string' ),
		'shipped_date' => array( 'type' => 'string' ),
	),
),

No billing address, no email, no phone number, no payment details. If a future workflow genuinely needs one of those fields, add it deliberately at that point, with its own review, rather than exposing the whole customer record up front because it happened to be available in the underlying data structure.

output_schema documents intent, it does not enforce it

Declaring a narrow output_schema communicates what the ability is supposed to return. It does not stop execute_callback from actually returning more, unless the MCP layer you’re using validates outbound responses against the schema strictly. Treat the schema as documentation for reviewers and models, and treat the code in Step 2 as the actual control.

Step 2: Redact explicitly inside execute_callback

The enforcement point is the return statement itself. Build the response as an explicit, deliberately narrow array, never (array) $order or similar shortcuts that pass through whatever fields happen to exist on the underlying object:

// File: my-plugin.php
'execute_callback' => function ( $input ) {
	$order = wc_get_order( $input['order_id'] );

	if ( ! $order ) {
		return new WP_Error( 'not_found', 'No order with that ID exists.' );
	}

	// Deliberately narrow. Never return $order->get_data() directly.
	return array(
		'order_id'     => $order->get_id(),
		'status'       => $order->get_status(),
		'shipped_date' => $order->get_date_completed() ? $order->get_date_completed()->date( 'Y-m-d' ) : null,
	);
},
Build the response as a fixed, explicit array
Never pass through a full object, model, or database row as-is.
Treat every field addition as a decision
Adding a field to the return array is a security-relevant change, review it like one.
Apply the same discipline to error messages
A WP_Error message that echoes back an email address on a "not found" path leaks data through a side channel.

Step 3: A concrete before-and-after

Consider an ability meant to let an agent answer “has this customer’s order shipped.”

// Wrong: exposes the entire order object
'execute_callback' => function ( $input ) {
	$order = wc_get_order( $input['order_id'] );
	return $order->get_data(); // includes billing email, phone, full address, payment method
},
// Right: exposes only what the stated purpose requires
'execute_callback' => function ( $input ) {
	$order = wc_get_order( $input['order_id'] );
	return array(
		'order_id' => $order->get_id(),
		'status'   => $order->get_status(),
	);
},

The second version is strictly less useful for any purpose beyond its stated one, and that’s the point. An ability that can only answer the question it was built for cannot be prompted, tricked, or accidentally used to exfiltrate data it never had access to.

Step 4: Why “the model probably will not misuse it” is not a control

Even a well-behaved, non-malicious AI client can still expose data it received: in a chat transcript a user later shares, in a log your MCP client or orchestration layer writes, in a summary the model produces for an unrelated downstream step, or simply by being asked directly by whoever has access to that conversation. None of this requires the model or the agent framework to do anything wrong. If an ability hands back a customer’s phone number, the honest assumption has to be that the phone number is now somewhere outside your database’s access controls, permanently.

Treat every returned field as effectively public to the conversation

Once data leaves execute_callback in a response, you no longer control where it goes. Decide what to expose based on that assumption, not on how careful you expect the receiving AI client to be.

Test it: audit an existing ability’s actual output

For any ability already exposed with meta.mcp.public set to true, call it directly and inspect exactly what comes back:

wp-env run cli wp eval '
$ability = wp_get_ability( "my-plugin/get-order-status" );
$result  = $ability->execute( array( "order_id" => 123 ) );
print_r( $result );
'

Compare the printed keys against the ability’s stated purpose. Anything present that isn’t required for that purpose should be removed.

Forgetting nested objects and arrays

A narrow top-level output_schema can still leak PII through a nested array, for example a line_items array where each item includes a customer note field that was never reviewed. Check nested structures with the same scrutiny as top-level fields.

Recap

The safest data an agent can leak is data it never received. Design output_schema around the actual stated purpose of each ability, build execute_callback responses as explicit, narrow arrays rather than passing through full objects, and treat any exposed field as effectively permanent and public to the conversation it ends up in. The next lesson extends this same caution to a related but different risk: untrusted, user submitted content being treated as instructions rather than data.

Resources & further reading

← Building Permission-Gated Tools Preventing Prompt Injection in Agent Workflows →