Skip to content
Closed
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
44 changes: 44 additions & 0 deletions actions/setup/js/generate_safe_outputs_tools.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,45 @@ function makeIntentFieldDescriptionsRequired(tool) {
}
}

/**
* Merge a workflow-provided labels item schema into the built-in object schema.
* Frontmatter validation guarantees that the configured schema can only narrow
* supported fields, so built-in constraints remain in force.
* @param {{inputSchema?: {properties?: Record<string, any>}}} tool
* @param {unknown} configuredSchema
*/
function applyAddLabelsItemSchema(tool, configuredSchema) {
if (!configuredSchema || typeof configuredSchema !== "object" || Array.isArray(configuredSchema)) {
return;
}
/** @type {any} */
const schema = configuredSchema;
const labelsSchema = tool.inputSchema?.properties?.labels;
if (!labelsSchema?.items) {
return;
}
const currentItems = labelsSchema.items;
const objectSchema = Array.isArray(currentItems.oneOf) ? currentItems.oneOf.find(/** @param {{type?: string}} schema */ schema => schema.type === "object") : currentItems.type === "object" ? currentItems : undefined;
if (!objectSchema) {
return;
}

const configuredProperties = schema.properties ?? {};
const properties = { ...(objectSchema.properties ?? {}) };
for (const [name, propertySchema] of Object.entries(configuredProperties)) {
properties[name] = { ...(properties[name] ?? {}), ...propertySchema };
Comment on lines +179 to +180
}
labelsSchema.items = {
...objectSchema,
...schema,
required: Array.from(new Set([...(objectSchema.required ?? []), ...(schema.required ?? [])])),
properties,
additionalProperties: false,
};
labelsSchema.description =
`Labels to add. Each label must be an object matching this workflow's item-schema; plain string label names are not permitted. Required fields: ${labelsSchema.items.required.join(", ")}. ` + "Labels must exist in the repository.";
}

/**
* Update add_comment description to match runtime-safe-output permissions.
* @param {string} description
Expand Down Expand Up @@ -383,6 +422,11 @@ async function main() {
}
}
}
if (tool.name === "add_labels") {
/** @type {any} */
const addLabelsConfig = config.add_labels;
applyAddLabelsItemSchema(enhancedTool, addLabelsConfig?.item_schema);
}

if (tool.name === "add_comment") {
enhancedTool.description = updateAddCommentDescription(enhancedTool.description, config.add_comment);
Expand Down
66 changes: 66 additions & 0 deletions actions/setup/js/generate_safe_outputs_tools.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,72 @@ describe("generate_safe_outputs_tools", () => {
expect(disabledTool.inputSchema.properties.labels.items.oneOf).toBeDefined();
});

it("merges add_labels item_schema with the built-in object schema", () => {
const addLabelsSourceTool = {
name: "add_labels",
description: "Adds labels.",
inputSchema: {
type: "object",
properties: {
labels: {
type: "array",
minItems: 1,
items: {
oneOf: [
{ type: "string" },
{
type: "object",
required: ["name"],
properties: {
name: { type: "string", description: "Label name." },
rationale: { type: "string", maxLength: 280 },
confidence: { type: "string", enum: ["LOW", "MEDIUM", "HIGH"] },
suggest: { type: "boolean" },
},
additionalProperties: false,
},
],
},
},
},
required: ["labels"],
},
};
fs.writeFileSync(toolsSourcePath, JSON.stringify([addLabelsSourceTool]));
fs.writeFileSync(
configPath,
JSON.stringify({
add_labels: {
item_schema: {
type: "object",
required: ["name", "confidence"],
additionalProperties: false,
properties: {
name: { type: "string" },
confidence: { type: "string", enum: ["HIGH", "MEDIUM"] },
suggest: { type: "boolean" },
},
},
},
})
);
fs.writeFileSync(toolsMetaPath, JSON.stringify({ description_suffixes: {}, repo_params: {}, dynamic_tools: [] }));

runScript();

const result = JSON.parse(fs.readFileSync(outputPath, "utf8"));
const items = result[0].inputSchema.properties.labels.items;
expect(items.oneOf).toBeUndefined();
expect(items.type).toBe("object");
expect(items.required).toEqual(["name", "confidence"]);
expect(items.additionalProperties).toBe(false);
expect(items.properties.confidence.enum).toEqual(["HIGH", "MEDIUM"]);
expect(items.properties.rationale.maxLength).toBe(280);
expect(items.properties.name.description).toBe("Label name.");
expect(result[0].inputSchema.properties.labels.description).toContain("Required fields: name, confidence");
expect(result[0].inputSchema.properties.labels.description).toContain("plain string label names are not permitted");
});

it("reflects required/optional/absent intent fields per tool configuration", () => {
fs.writeFileSync(
toolsSourcePath,
Expand Down
75 changes: 72 additions & 3 deletions actions/setup/js/mcp_scripts_validation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ function validateSchemaNode(value, schema, path, options = {}) {
}
}

if (typeof value === "string" && typeof schema.maxLength === "number") {
const characterLength = Array.from(value).length;
if (characterLength > schema.maxLength) {
return {
path,
message: `must be at most ${schema.maxLength} characters`,
expected: `maxLength ${schema.maxLength}`,
received: `${characterLength} characters`,
};
}
}

if (isPlainObject(value)) {
if (!options.skipRequiredAtRoot || path !== "") {
const required = Array.isArray(schema.required) ? schema.required : [];
Expand Down Expand Up @@ -280,16 +292,73 @@ function validateValueAgainstSchema(value, schema) {
return validateSchemaNode(value, schema, "", { skipRequiredAtRoot: false });
}

function formatSchemaValidationError(toolName, args, error) {
/**
* Build an example value for a single property from its schema.
* @param {string} field
* @param {any} propertySchema
* @returns {any}
*/
function exampleValueForProperty(field, propertySchema) {
const schema = isPlainObject(propertySchema) ? propertySchema : {};
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
return schema.enum[schema.enum.length - 1];
}
const type = Array.isArray(schema.type) ? schema.type[0] : schema.type;
switch (type) {
case "boolean":
return false;
case "number":
case "integer":
return 1;
case "array":
return [];
case "object":
return {};
default:
return EXAMPLE_LABEL_FIELD_VALUES[field] || field;
}
}

const EXAMPLE_LABEL_FIELD_VALUES = {
name: "bug",
rationale: "Why this label applies",
confidence: "HIGH",
};

const DEFAULT_LABEL_REQUIRED_FIELDS = ["name", "rationale", "confidence"];

/**
* Build a schema-compliant example label object from the effective item schema.
* Falls back to the default intent fields when no schema is available.
* @param {string[]} required
* @param {any} itemSchema
* @returns {string}
*/
function buildLabelExample(required, itemSchema) {
const schema = isPlainObject(itemSchema) ? itemSchema : {};
const properties = isPlainObject(schema.properties) ? schema.properties : {};
/** @type {Record<string, any>} */
const example = {};
for (const field of required) {
example[field] = exampleValueForProperty(field, properties[field]);
}
return JSON.stringify(example);
}

function formatSchemaValidationError(toolName, args, error, inputSchema) {
if (toolName === "add_labels" && typeof error?.path === "string" && /^labels\[\d+\]$/.test(error.path) && Array.isArray(args?.labels)) {
const index = Number(error.path.match(/^labels\[(\d+)\]$/)?.[1] || -1);
const receivedLabel = index >= 0 ? args.labels[index] : undefined;
if (typeof receivedLabel === "string") {
const itemSchema = inputSchema?.properties?.labels?.items;
const objectSchema = Array.isArray(itemSchema?.oneOf) ? itemSchema.oneOf.find(/** @param {any} option */ option => option?.type === "object") : itemSchema;
const schemaRequired = objectSchema?.required;
const requiredFields = Array.isArray(schemaRequired) && schemaRequired.length > 0 ? schemaRequired : DEFAULT_LABEL_REQUIRED_FIELDS;
return [
"Invalid arguments for add_labels:",
` ${error.path} must be an object (string shorthand is not supported).`,
' Expected: {"name":"bug","rationale":"Why this label applies","confidence":"HIGH"}',
" Required fields: name, rationale, confidence",
` Expected: ${buildLabelExample(requiredFields, objectSchema)}`,
` Required fields: ${requiredFields.join(", ")}`,
` Received: ${JSON.stringify(receivedLabel)}`,
].join("\n");
}
Expand Down
65 changes: 65 additions & 0 deletions actions/setup/js/mcp_scripts_validation.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,32 @@ describe("mcp_scripts_validation.cjs", () => {
});
expect(validateArgumentsAgainstSchema({ labels: [{ name: "bug", confidence: "HIGH" }] }, schema)).toBeNull();
});

it("enforces maxLength on nested string values", async () => {
const { validateArgumentsAgainstSchema } = await import("./mcp_scripts_validation.cjs");
const schema = {
type: "object",
properties: {
labels: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
rationale: { type: "string", maxLength: 280 },
},
required: ["name"],
},
},
},
};

expect(validateArgumentsAgainstSchema({ labels: [{ name: "bug", rationale: "x".repeat(281) }] }, schema)).toMatchObject({
path: "labels[0].rationale",
message: "must be at most 280 characters",
});
expect(validateArgumentsAgainstSchema({ labels: [{ name: "bug", rationale: "x".repeat(280) }] }, schema)).toBeNull();
});
});

describe("formatSchemaValidationError", () => {
Expand All @@ -579,5 +605,44 @@ describe("mcp_scripts_validation.cjs", () => {
expect(message).toContain("Required fields: name, rationale, confidence");
expect(message).toContain('Received: \"bug\"');
});

it("formats workflow-specific add_labels required fields", async () => {
const { formatSchemaValidationError } = await import("./mcp_scripts_validation.cjs");
const inputSchema = {
properties: {
labels: {
items: {
type: "object",
required: ["name", "confidence"],
},
},
},
};
const message = formatSchemaValidationError("add_labels", { labels: ["bug"] }, { path: "labels[0]", message: "must be a object" }, inputSchema);
expect(message).toContain("Required fields: name, confidence");
expect(message).not.toContain("Required fields: name, rationale, confidence");
});

it("builds the add_labels example from the effective item schema", async () => {
const { formatSchemaValidationError } = await import("./mcp_scripts_validation.cjs");
const inputSchema = {
properties: {
labels: {
items: {
type: "object",
required: ["name", "confidence", "suggest"],
properties: {
name: { type: "string" },
confidence: { type: "string", enum: ["LOW"] },
suggest: { type: "boolean" },
},
},
},
},
};
const message = formatSchemaValidationError("add_labels", { labels: ["bug"] }, { path: "labels[0]", message: "must be a object" }, inputSchema);
expect(message).toContain('Expected: {"name":"bug","confidence":"LOW","suggest":false}');
expect(message).toContain("Required fields: name, confidence, suggest");
});
});
});
26 changes: 13 additions & 13 deletions actions/setup/js/mcp_server_core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -790,20 +790,20 @@ async function handleRequest(server, request, defaultHandler) {
};
}

const schemaValidationError = validateArgumentsAgainstSchema(args, tool.inputSchema);
if (schemaValidationError) {
// SM-IS-01: Validate per-string input length limits (default 10 KB, or explicit schema maxLength when set).
const oversizedFields = validateStringInputLengths(args, tool.inputSchema);
if (oversizedFields.length) {
throw {
code: -32602,
message: formatSchemaValidationError(name, args, schemaValidationError),
message: buildStringLengthValidationError(name, oversizedFields),
};
}

// SM-IS-01: Validate per-string input length limits (default 10 KB, or explicit schema maxLength when set).
const oversizedFields = validateStringInputLengths(args, tool.inputSchema);
if (oversizedFields.length) {
const schemaValidationError = validateArgumentsAgainstSchema(args, tool.inputSchema);
if (schemaValidationError) {
throw {
code: -32602,
message: buildStringLengthValidationError(name, oversizedFields),
message: formatSchemaValidationError(name, args, schemaValidationError, tool.inputSchema),
};
}

Expand Down Expand Up @@ -967,19 +967,19 @@ async function handleMessage(server, req, defaultHandler) {
return;
}

const schemaValidationError = validateArgumentsAgainstSchema(args, tool.inputSchema);
if (schemaValidationError) {
server.replyError(id, -32602, formatSchemaValidationError(name, args, schemaValidationError));
return;
}

// SM-IS-01: Validate per-string input length limits (default 10 KB, or explicit schema maxLength when set).
const oversized = validateStringInputLengths(args, tool.inputSchema);
if (oversized.length) {
server.replyError(id, -32602, buildStringLengthValidationError(name, oversized));
return;
}

const schemaValidationError = validateArgumentsAgainstSchema(args, tool.inputSchema);
if (schemaValidationError) {
server.replyError(id, -32602, formatSchemaValidationError(name, args, schemaValidationError, tool.inputSchema));
return;
}

// Validate minLength constraints from the schema.
const tooShort = validateStringMinLengths(args, tool.inputSchema);
if (tooShort.length) {
Expand Down
13 changes: 13 additions & 0 deletions docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,8 @@ Use `required-labels` to only add labels to issues/PRs that already have **all**

By default, labels that don't already exist in the target repository are rejected with an error. Set `create-if-missing: true` to automatically create any missing labels before they are applied.

Use `item-schema` to require structured label items or narrow their accepted values. The schema is merged with the built-in label object schema, so it can only require `name`, `rationale`, `confidence`, or `suggest` and restrict those fields with compatible types or enums. The narrowed schema is enforced by the MCP tool, allowing the agent to correct invalid calls immediately.

```yaml wrap
safe-outputs:
add-labels:
Expand All @@ -495,6 +497,15 @@ safe-outputs:
required-labels: [automated, bot] # only operate if item has ALL of these labels
required-title-prefix: "[bot] " # only operate if item title starts with this prefix
create-if-missing: true # auto-create labels that don't already exist (default: false)
item-schema:
type: object
required: [name, confidence]
additionalProperties: false
properties:
name: { type: string }
confidence: { type: string, enum: [HIGH, MEDIUM, LOW] }
suggest: { type: boolean }
rationale: { type: string }
```

#### Blocked Label Patterns
Expand Down Expand Up @@ -525,6 +536,8 @@ safe-outputs:

Removes labels from issues or PRs. Specify `allowed` to restrict which labels can be removed (specific labels or glob patterns), or `blocked` to prevent removal of specific label patterns. If a label is not present on the item, it will be silently skipped.

`remove_labels` accepts structured label objects for compatibility, but ignores their `rationale`, `confidence`, and `suggest` fields.

Use `required-labels` to only remove labels from issues/PRs that already have **all** of the specified labels. Use `required-title-prefix` to only remove labels from issues/PRs whose title starts with the given prefix.

```yaml wrap
Expand Down
Loading
Loading