Conversation
5541552 to
875e0ed
Compare
| json_schema: | ||
| description: "JSON schema for structured output validation. When provided, Claude will return validated JSON matching this schema, and the action will automatically set GitHub Action outputs for each field." | ||
| required: false | ||
| default: "" |
There was a problem hiding this comment.
The json_schema input should also be added to base-action/action.yml. Currently, the base action can't be used directly with structured outputs because it lacks this input definition.
Additionally, consider documenting in the description that this input creates dynamic outputs for each schema field (e.g., "When provided, automatically sets GitHub Action outputs for each field - access via steps.id.outputs.field_name").
| default: | ||
| return JSON.stringify(value); | ||
| } | ||
| } |
There was a problem hiding this comment.
| } | |
| default: | |
| return value === undefined ? "undefined" : String(value); |
The default case has a bug: JSON.stringify(undefined), JSON.stringify(Symbol()), and JSON.stringify(Function) all return undefined (not a string), which would cause runtime errors when passed to core.setOutput().
Recommend explicitly handling these cases or using String(value) as a fallback.
| for (const [key, value] of entries) { | ||
| const sanitizedKey = sanitizeOutputName(key); | ||
| if (!sanitizedKey) { | ||
| core.warning(`Skipping invalid output key: "${key}"`); |
There was a problem hiding this comment.
This check can never trigger for non-empty keys because sanitizeOutputName() replaces invalid characters with underscores, so it never returns an empty string unless the input was already empty.
Edge case: A key like "@@@@" becomes "____" and passes through. Consider adding validation:
if (!sanitizedKey || /^_+$/.test(sanitizedKey) || sanitizedKey !== key) {
core.warning(`Output key "${key}" was sanitized to "${sanitizedKey}"`);
}| executionFile: string, | ||
| ): Promise<void> { | ||
| try { | ||
| const content = await readFile(executionFile, "utf-8"); |
There was a problem hiding this comment.
Performance opportunity: The execution file is parsed twice:
- At lines 382-385 when creating the execution file
- Here when extracting structured outputs
For large outputs (1-10MB), this adds 20-500ms overhead. Consider passing the already-parsed messages array instead of re-reading the file:
// After line 385, parse once:
const messages = JSON.parse(jsonOutput);
await writeFile(EXECUTION_FILE, jsonOutput);
// Then at line 396:
if (process.env.JSON_SCHEMA) {
await parseAndSetStructuredOutputs(messages); // Pass array, not file path
}| default: | ||
| return JSON.stringify(value); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Test coverage gap: These tests duplicate the implementation logic inline instead of testing the actual functions. If the real sanitizeOutputName() or convertToString() implementations change, these tests will still pass.
Consider either:
- Exporting these functions and testing them directly, or
- Testing them indirectly through
parseAndSetStructuredOutputs()with mockedcore.setOutput()
Also missing tests for:
undefined,Symbol, andFunctionvalues inconvertToString()(would currently cause errors)- File read errors (ENOENT)
- Empty string keys
- Large outputs (1000+ fields)
| core.setOutput("execution_file", EXECUTION_FILE); | ||
|
|
||
| // Parse and set structured outputs only if user provided json_schema | ||
| if (process.env.JSON_SCHEMA) { |
There was a problem hiding this comment.
Error handling issue: After setting conclusion: "success" at line 393, the structured output parsing at line 397 can call core.setFailed(), creating a confusing state.
Consider either:
- Moving structured output parsing before setting the conclusion
- Making
parseAndSetStructuredOutputs()return a boolean and handling failure appropriately
Also, the error handling for file processing differs between success (warns at line 384) and failure paths (silently ignores) - these should be consistent.
| // Parse and set structured outputs only if user provided json_schema | ||
| if (process.env.JSON_SCHEMA) { | ||
| await parseAndSetStructuredOutputs(EXECUTION_FILE); | ||
| } |
There was a problem hiding this comment.
Critical Issue: Missing Environment Variable
The JSON_SCHEMA environment variable is checked here, but it's not being passed to the base-action in the main action.yml. Looking at line 181 of action.yml, JSON_SCHEMA is only passed to the prepare step, not to the base-action run step.
This means structured outputs won't work when using the base-action standalone. Need to add to the base-action environment:
- name: Run Base Action
env:
JSON_SCHEMA: ${{ inputs.json_schema }}
base-action/src/run-claude.ts
Outdated
| ); | ||
|
|
||
| if (!result?.structured_output) { | ||
| core.setFailed( |
There was a problem hiding this comment.
Error Handling Issue
Calling core.setFailed() doesn't stop execution - the function returns but the workflow may continue to subsequent steps. Should throw an error to ensure proper failure propagation:
if (!result?.structured_output) {
const error = new Error(
"json_schema was provided but Claude did not return structured_output. " +
"The schema may be invalid or Claude failed to call the StructuredOutput tool."
);
core.setFailed(error.message);
throw error; // Ensures the action fails properly
}| // Add JSON schema if provided | ||
| const jsonSchema = process.env.JSON_SCHEMA || ""; | ||
| if (jsonSchema) { | ||
| const escapedSchema = jsonSchema.replace(/'/g, "'\\''"); |
There was a problem hiding this comment.
Security: Command Injection Risk
While the single-quote escaping is a standard technique, passing user-controlled input through shell command construction is risky. Consider using an environment variable or file-based approach instead:
// Option 1: Environment variable
env: {
CLAUDE_JSON_SCHEMA: jsonSchema,
}
// Option 2: Temporary file
const schemaPath = `${process.env.RUNNER_TEMP}/json-schema.json`;
await writeFile(schemaPath, jsonSchema);
claudeArgs += ` --json-schema-file "${schemaPath}"`;Also recommend validating it's valid JSON first:
try {
JSON.parse(jsonSchema);
} catch (e) {
throw new Error("Invalid JSON schema provided");
}| executionFile: string, | ||
| ): Promise<void> { | ||
| try { | ||
| const content = await readFile(executionFile, "utf-8"); |
There was a problem hiding this comment.
Performance: Unnecessary File I/O
The execution file was just created from the output variable that's already in memory (line 385). Reading it back from disk adds unnecessary I/O overhead (10-50ms for typical outputs).
Consider passing the parsed messages array directly:
// After parsing output for execution file (around line 382)
const messages = JSON.parse(jsonOutput) as ExecutionMessage[];
await writeFile(EXECUTION_FILE, jsonOutput);
// Later, pass messages directly (line 396)
if (process.env.JSON_SCHEMA) {
await parseAndSetStructuredOutputs(messages); // Instead of EXECUTION_FILE
}Update function signature:
async function parseAndSetStructuredOutputs(
messages: ExecutionMessage[],
): Promise<void>| continue; | ||
| } | ||
|
|
||
| const stringValue = convertToString(value); |
There was a problem hiding this comment.
Missing Output Size Validation
GitHub Actions has a 1MB limit per output field. Large structured outputs could exceed this and cause workflow failures. Recommend adding validation:
const MAX_OUTPUT_SIZE = 100000; // 100KB per field
const stringValue = convertToString(value);
if (stringValue.length > MAX_OUTPUT_SIZE) {
core.warning(
`Output ${sanitizedKey} exceeds safe size (${stringValue.length} bytes), truncating to ${MAX_OUTPUT_SIZE} bytes`
);
core.setOutput(sanitizedKey, stringValue.substring(0, MAX_OUTPUT_SIZE));
} else {
core.setOutput(sanitizedKey, stringValue);
}| const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; | ||
| const BASE_ARGS = ["--verbose", "--output-format", "stream-json"]; | ||
|
|
||
| type ExecutionMessage = { |
There was a problem hiding this comment.
Type Safety: Weak Type Definition
The ExecutionMessage type is too permissive. Consider using discriminated unions for better type safety:
type ExecutionMessage =
| {
type: "system";
subtype: "init" | "shutdown";
model?: string;
}
| {
type: "result";
cost_usd?: number;
duration_ms?: number;
structured_output?: Record<string, unknown>;
}
| {
type: "turn" | "tool_use" | "tool_result";
content?: string;
[key: string]: unknown;
};This makes the code more self-documenting and catches errors at compile time.
Code Review SummaryI've completed a comprehensive review of PR #682 using specialized review agents. Overall, this is a well-implemented feature with excellent test coverage, but there are some important issues to address before merging. Critical Issues
|
Add support for Agent SDK structured outputs feature. New input: json_schema - accepts JSON schema for validated outputs Auto-sets GitHub Action outputs for each field in the structured result Docs: https://docs.claude.com/en/docs/agent-sdk/structured-outputs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
875e0ed to
c102f7c
Compare
|
Closing to create fresh PR without automated review noise |
| "The schema may be invalid or Claude failed to call the StructuredOutput tool.", | ||
| ); | ||
| core.setFailed(error.message); | ||
| throw error; |
There was a problem hiding this comment.
Critical Issue: Redundant error handling causes duplicate failures and loses stack traces
This function calls core.setFailed() and throws here, then catches and does it again at line 206-208. This creates:
- Duplicate error messages in logs
- Loss of original error stack traces
- Confusion about which error is the "real" one
Recommendation: Remove the try-catch wrapper and let errors propagate naturally, handling them at the call site (line 403) instead:
// Remove the outer try-catch from parseAndSetStructuredOutputs
// Handle errors at call site:
if (process.env.JSON_SCHEMA) {
try {
await parseAndSetStructuredOutputs(EXECUTION_FILE);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
core.setFailed(`Failed to parse structured outputs: ${errorMsg}`);
throw error;
}
}This preserves stack traces and avoids calling core.setFailed() twice.
| } | ||
|
|
||
| // Set GitHub Action output for each field | ||
| const entries = Object.entries(result.structured_output); |
There was a problem hiding this comment.
Security: Potential prototype pollution vulnerability
Using Object.entries() without filtering inherited properties could iterate over __proto__, constructor, or prototype keys if present in a malicious Claude response. While @actions/core v1.10.1 mitigates classic injection, setting these as outputs could:
- Pollute downstream workflow steps that parse outputs as JSON
- Cause unexpected behavior in dynamic output references
- Create security audit confusion
Recommendation: Add hasOwnProperty check:
for (const [key, value] of entries) {
if (!Object.prototype.hasOwnProperty.call(result.structured_output, key)) {
core.warning(`Skipping inherited property: "${key}"`);
continue;
}
// ... rest of code
}Reference: CWE-1321
|
|
||
| for (const [key, value] of entries) { | ||
| const sanitizedKey = sanitizeOutputName(key); | ||
| if (!sanitizedKey) { |
There was a problem hiding this comment.
Security: Missing validation for reserved output names
A malicious schema could include fields named conclusion or execution_file, which would overwrite the action's critical outputs set at lines 398-399. This could cause:
- Workflow failures due to missing/corrupted execution files
- Incorrect status reporting (
conclusionbeing overwritten) - Difficult-to-debug issues
Additionally, keys consisting entirely of special characters (e.g., "@@@") would sanitize to "___" which passes this check but creates ambiguous outputs.
Recommendation: Validate against reserved names:
const RESERVED_NAMES = ['conclusion', 'execution_file'];
if (RESERVED_NAMES.includes(sanitizedKey.toLowerCase())) {
core.warning(`Skipping reserved output name: "${key}"`);
continue;
}
// Also ensure at least one alphanumeric character
if (!/[a-zA-Z0-9]/.test(sanitizedKey)) {
core.warning(`Skipping invalid output key after sanitization: "${key}"`);
continue;
}| core.setOutput("conclusion", "success"); | ||
| core.setOutput("execution_file", EXECUTION_FILE); | ||
|
|
||
| // Parse and set structured outputs only if user provided json_schema | ||
| if (process.env.JSON_SCHEMA) { | ||
| await parseAndSetStructuredOutputs(EXECUTION_FILE); |
There was a problem hiding this comment.
Critical Issue: Race condition between file creation and structured output parsing
If the execution file write fails at line 391 (caught and warned at line 395), parseAndSetStructuredOutputs() will still be called with a potentially missing or corrupted file. This creates confusing error messages.
Recommendation: Only call parseAndSetStructuredOutputs if the file was successfully created:
let executionFileCreated = false;
try {
await writeFile("output.txt", output);
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
maxBuffer: 10 * 1024 * 1024,
});
await writeFile(EXECUTION_FILE, jsonOutput);
executionFileCreated = true;
console.log(`Log saved to ${EXECUTION_FILE}`);
} catch (e) {
core.warning(`Failed to process output for execution metrics: ${e}`);
}
core.setOutput("conclusion", "success");
if (executionFileCreated) {
core.setOutput("execution_file", EXECUTION_FILE);
if (process.env.JSON_SCHEMA) {
await parseAndSetStructuredOutputs(EXECUTION_FILE);
}
}| const jsonSchema = process.env.JSON_SCHEMA || ""; | ||
| if (jsonSchema) { | ||
| // Validate it's valid JSON | ||
| try { | ||
| JSON.parse(jsonSchema); | ||
| } catch (e) { | ||
| throw new Error(`Invalid JSON schema provided: ${e}`); | ||
| } | ||
| const escapedSchema = jsonSchema.replace(/'/g, "'\\''"); | ||
| claudeArgs += ` --json-schema '${escapedSchema}'`; | ||
| } |
There was a problem hiding this comment.
Code Quality: Duplicated JSON schema validation logic
This exact validation and escaping code is duplicated in src/modes/tag/index.ts (lines 181-191). This violates DRY principle and creates maintenance burden.
Recommendation: Extract to a shared utility:
// src/modes/utils/json-schema.ts
export function buildJsonSchemaArgs(): string {
const jsonSchema = process.env.JSON_SCHEMA || "";
if (!jsonSchema) {
return "";
}
try {
JSON.parse(jsonSchema);
} catch (e) {
throw new Error(`Invalid JSON schema provided: ${e}`);
}
const escapedSchema = jsonSchema.replace(/'/g, "'\\''");
return ` --json-schema '${escapedSchema}'`;
}Then use claudeArgs += buildJsonSchemaArgs(); in both files.
Comprehensive Code Review: PR #682I've completed a thorough review using specialized agents across 5 key areas. Here's a summary of the most noteworthy findings: Critical Issues (Must Fix Before Merge)1. Race Condition in File ProcessingLocation:
2. Redundant Error HandlingLocation:
3. Security: Prototype Pollution RiskLocation:
4. Security: Missing Reserved Name ValidationLocation:
5. Missing Environment Variable Wiring (BLOCKER)Location:
Important Issues (Should Fix)6. Code DuplicationLocation:
7. Missing Documentation
Test Coverage Gaps8. Zero Error Path Testing
Performance Assessment✅ No performance issues identified. Implementation is well-optimized:
Security SummaryOverall Security Posture: Moderate - requires improvements Positive:
Needs Fixing:
Recommendations by PriorityBefore Merge:
Follow-up PR: Positive Observations
I've posted inline comments on the most critical code locations. Please address the blocking issues before merge. |
Add support for Agent SDK structured outputs.
New Feature
json_schema- JSON schema for validated outputsDocumentation
https://docs.claude.com/en/docs/agent-sdk/structured-outputs
Examples
examples/test-failure-analysis.yml- Auto-retry flaky tests.github/workflows/test-structured-output.yml- Integration testsTests