Skip to content

Conversation

@ajag408
Copy link
Contributor

@ajag408 ajag408 commented Jan 9, 2026

Summary

This PR adds a JSON-based CLI to Shield, enabling validation from any programming language via standalone binaries. No Node.js required for end users.

Motivation

Shield is written in TypeScript, limiting its use to JavaScript/TypeScript applications. Many Yield.xyz integrators use Python, Go, Rust, or other languages for their backends. Rather than maintaining multiple implementations (which risks inconsistencies and security gaps), this PR provides:

  1. Universal JSON interface via stdin/stdout
  2. Standalone binaries for all major platforms (no Node.js dependency)
  3. Complete integration examples for Python, Go, and Rust

What's Added

Core JSON Interface

File Purpose
src/json/types.ts TypeScript types for JSON request/response
src/json/schema.ts Ajv JSON schema for strict input validation
src/json/handler.ts Main request handler (parse → validate → route → respond)
src/json/handler.test.ts Comprehensive test suite (23 tests)
src/cli.ts CLI entry point (reads stdin, writes stdout)

Standalone Binary Support (SEA)

File Purpose
sea-config.json Node.js Single Executable Application config
scripts/build-sea.sh Unix binary build script
scripts/build-sea.ps1 Windows binary build script
.github/workflows/release.yml Multi-platform binary release workflow

Integration Examples

Directory Language Features
examples/python/ Python Complete validation example with error handling
examples/go/ Go Strongly-typed Shield client
examples/rust/ Rust Async Shield client with serde

Documentation

File Purpose
docs/integration-python.md Python integration guide
docs/integration-go.md Go integration guide
docs/integration-rust.md Rust integration guide
examples/README.md Quick start for all examples

Binary Releases

Pre-built binaries for all platforms (~87MB each, includes Node.js runtime):

Platform Binary
Linux (x64) shield-linux-x64
macOS (Apple Silicon) shield-darwin-arm64
macOS (Intel) shield-darwin-x64
Windows shield-windows-x64.exe

Security Enhancements

Feature Purpose
SHA256 checksums Verify binary integrity after download
Artifact attestation Cryptographic proof binaries were built by GitHub Actions
Least privilege permissions Workflow uses minimal required permissions
Dependency audit pnpm audit runs before binary builds
Input validation 100KB limit, strict JSON schema, no unknown properties

JSON Protocol

Request:

{
  "apiVersion": "1.0",
  "operation": "validate",
  "yieldId": "ethereum-eth-lido-staking",
  "unsignedTransaction": "{...}",
  "userAddress": "0x..."
}

Response:

{
  "ok": true,
  "apiVersion": "1.0",
  "result": { "isValid": true, "detectedType": "STAKE" },
  "meta": { "requestHash": "sha256..." }
}

Usage

# Using npm (requires Node.js)
echo '{"apiVersion":"1.0","operation":"getSupportedYieldIds"}' | npx @yieldxyz/shield

# Using standalone binary (no Node.js required)
echo '{"apiVersion":"1.0","operation":"getSupportedYieldIds"}' | ./shield

Testing

pnpm test                    # Run all tests
pnpm build:binary            # Build local binary
./shield-darwin-arm64 < test-input.json  # Test binary

Breaking Changes

None. This is purely additive.

Checklist

  • All tests pass (pnpm test)
  • Build succeeds (pnpm build)
  • Binary builds succeed (pnpm build:binary)
  • CLI works after build
  • Examples tested (Python, Go, Rust)
  • Documentation updated
  • Security checksums and attestations configured

Copilot AI review requested due to automatic review settings January 9, 2026 06:37
@socket-security
Copy link

socket-security bot commented Jan 9, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​esbuild@​0.27.2911007389100
Addedcargo/​serde@​1.0.2288110093100100
Addednpm/​ajv@​8.17.19910010082100
Addedcargo/​serde_json@​1.0.1498210093100100
Addednpm/​postject@​1.0.0-alpha.610010010084100

View full report

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a JSON-based interface and CLI to Shield, enabling cross-language validation of transactions from any programming language (Python, Go, Ruby, Rust, etc.) without requiring native Shield implementations. The change introduces a standardized JSON protocol for stdin/stdout communication, comprehensive input validation using Ajv JSON Schema, and security features including input size limits, strict schema validation, and fail-closed error handling.

Key changes:

  • JSON Interface: New JSON request/response types, Ajv-based schema validation, and request handler with operation routing
  • CLI Tool: Command-line interface that reads JSON from stdin and writes responses to stdout
  • Documentation: Comprehensive README updates with examples for Bash, Python, Go, and Ruby

Reviewed changes

Copilot reviewed 10 out of 13 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
src/json/types.ts TypeScript type definitions for JSON requests, responses, and error codes
src/json/schema.ts Ajv JSON schema with security constraints and operation-specific field requirements
src/json/handler.ts Main request handler with parsing, validation, routing, and response formatting
src/json/handler.test.ts Comprehensive test suite covering validation, security, and error cases
src/json/index.ts Public exports for JSON interface types and functions
src/cli.ts CLI entry point that reads stdin and invokes the JSON handler
src/index.ts Updated exports to include JSON interface functions and types
rslib.config.ts Added CLI build configuration for CommonJS output
package.json Added bin field for CLI executable and ajv dependency
pnpm-lock.yaml Lockfile updates for ajv 8.17.1 and its dependencies
README.md Added cross-language usage documentation with CLI and language-specific examples
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +75 to +79
},
},
},
},
},
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The operationRequirements object is not type-safe. The keys are not constrained to match the operation enum values, and there's no compile-time guarantee that all operations are covered. Consider using a Record type with the operation type as the key, or use a const assertion with 'as const' and 'satisfies' to ensure type safety.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +5

const MAX_INPUT_SIZE = 100 * 1024; // 100KB

Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MAX_INPUT_SIZE constant is duplicated between cli.ts and handler.ts. This creates a maintenance burden and risk of inconsistency. Consider exporting the constant from handler.ts and importing it in cli.ts, or defining it in a shared constants file to ensure both use the same limit.

Suggested change
const MAX_INPUT_SIZE = 100 * 1024; // 100KB
import { MAX_INPUT_SIZE } from './handler';

Copilot uses AI. Check for mistakes.
raiseerco
raiseerco previously approved these changes Jan 15, 2026
Copy link

@raiseerco raiseerco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, just a small comment

raiseerco
raiseerco previously approved these changes Jan 16, 2026
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 24 changed files in this pull request and generated 12 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

},
meta: { requestHash: 'unavailable' },
};
process.stdout.write(JSON.stringify(errorResponse) + '\n');
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling in the CLI catches all errors and exits with code 1, but it doesn't properly destroy the stdin stream before exiting. If an error occurs during input reading (like when input exceeds the size limit), the promise rejection will be caught but the stdin stream may not be cleaned up properly. Consider adding process.stdin.destroy() in the error handler to ensure proper cleanup.

Suggested change
process.stdout.write(JSON.stringify(errorResponse) + '\n');
process.stdout.write(JSON.stringify(errorResponse) + '\n');
process.stdin.destroy();

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +131
} catch (e) {
// SECURITY: Never expose internal error details in production
return JSON.stringify(
errorResponse(
'INTERNAL_ERROR',
'An unexpected error occurred',
requestHash,
),
);
}
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block at line 122 suppresses all error details for security reasons, but it doesn't log the error anywhere for debugging purposes. In a production environment, operators need to be able to diagnose issues. Consider adding structured logging (that doesn't expose sensitive details to end users) or at least logging to stderr for operational visibility while keeping stdout clean for the JSON response.

Copilot uses AI. Check for mistakes.

- name: Rename binary (Unix)
if: matrix.platform != 'win32'
run: mv shield-${{ matrix.platform }}-* shield-${{ matrix.platform }}-${{ matrix.arch }}
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The binary renaming step uses a wildcard pattern shield-${{ matrix.platform }}-* which could match multiple files if there are leftover artifacts from previous builds. This could lead to unintended files being renamed. Consider using the exact filename pattern shield-${{ matrix.platform }}-${{ matrix.arch }} or adding a cleanup step before the build to ensure a clean workspace.

Suggested change
run: mv shield-${{ matrix.platform }}-* shield-${{ matrix.platform }}-${{ matrix.arch }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
files=(shield-${{ matrix.platform }}-*)
if [ "${#files[@]}" -ne 1 ]; then
echo "Error: Expected exactly one built binary matching 'shield-${{ matrix.platform }}-*', found ${#files[@]}: ${files[*]-}" >&2
exit 1
fi
mv "${files[0]}" "shield-${{ matrix.platform }}-${{ matrix.arch }}"

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +66
result = subprocess.run(
[shield_path],
input=json.dumps(request),
capture_output=True,
text=True,
)

response = json.loads(result.stdout)

Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function doesn't handle cases where subprocess.run fails or returns a non-zero exit code. If the Shield binary doesn't exist, isn't executable, or encounters a catastrophic error, subprocess.run could raise an exception or result.stdout might be empty/invalid JSON. This would cause json.loads to fail with an unhandled exception. Consider adding try-except error handling and checking result.returncode before attempting to parse stdout.

Suggested change
result = subprocess.run(
[shield_path],
input=json.dumps(request),
capture_output=True,
text=True,
)
response = json.loads(result.stdout)
try:
result = subprocess.run(
[shield_path],
input=json.dumps(request),
capture_output=True,
text=True,
)
except Exception as e:
# Failed to start or communicate with the Shield process
return False, ShieldError(
code="subprocess_error",
message=f"Failed to execute Shield binary: {e}",
)
if result.returncode != 0:
stderr = (result.stderr or "").strip()
message = f"Shield process failed with exit code {result.returncode}"
if stderr:
message += f": {stderr}"
return False, ShieldError(
code="shield_process_failed",
message=message,
)
if not result.stdout:
return False, ShieldError(
code="empty_response",
message="Shield returned no output.",
)
try:
response = json.loads(result.stdout)
except json.JSONDecodeError as e:
return False, ShieldError(
code="invalid_json",
message=f"Failed to parse Shield response as JSON: {e}",
)

Copilot uses AI. Check for mistakes.
.stdout(Stdio::piped())
.spawn()?;

child.stdin.as_mut().unwrap().write_all(input.as_bytes())?;
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stdin stream needs to be closed before calling wait_with_output(), otherwise the child process will continue waiting for more input. After writing to stdin, the stream should be explicitly dropped or closed. Consider adding drop(child.stdin.take()); after line 56 to ensure the stream is closed before waiting for output.

Suggested change
child.stdin.as_mut().unwrap().write_all(input.as_bytes())?;
{
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes())?;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +16
const platform = os.platform();
const arch = os.arch();
const binaryName =
platform === 'win32' ? 'shield.exe' : `shield-${platform}-${arch}`;
const nodePath = process.execPath;

console.log(`Building SEA for ${platform}-${arch}...`);

// Step 1: Copy node binary
console.log('Copying Node.js binary...');
fs.copyFileSync(nodePath, binaryName);
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The binary name pattern uses a conditional expression that may produce incorrect results on Windows. When platform === 'win32', the ternary evaluates to 'shield.exe', but for non-Windows platforms it constructs shield-${platform}-${arch}. However, on line 16 the binary is copied with this name, and later the script expects to find it. The issue is that on macOS during the build process, os.arch() might return a different architecture than what's being targeted. Consider explicitly passing the target platform and architecture as arguments rather than relying on os.platform() and os.arch().

Copilot uses AI. Check for mistakes.
# SECURITY: Run audit before building
- name: Security audit
run: pnpm audit --audit-level=high
continue-on-error: true
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflow uses continue-on-error: true for the security audit step, which means the build will proceed even if high-severity vulnerabilities are found. This undermines the security guarantees mentioned in the PR description. Consider either removing continue-on-error or at least adding a follow-up step that reviews the audit results and blocks the release if critical vulnerabilities are found.

Suggested change
continue-on-error: true

Copilot uses AI. Check for mistakes.
.stdout(Stdio::piped())
.spawn()?;

child.stdin.as_mut().unwrap().write_all(input.as_bytes())?;
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using .unwrap() on child.stdin can panic if stdin wasn't properly created, though this is unlikely given the Stdio::piped() configuration. For a production-ready example, consider using proper error handling with ok_or_else() or matching on the Option to provide a better error message if stdin is unavailable.

Suggested change
child.stdin.as_mut().unwrap().write_all(input.as_bytes())?;
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "failed to open child stdin"))?;
stdin.write_all(input.as_bytes())?;

Copilot uses AI. Check for mistakes.
.stdout(Stdio::piped())
.spawn()?;

child.stdin.as_mut().unwrap().write_all(input.as_bytes())?;
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stdin stream needs to be closed before calling wait_with_output(), otherwise the child process will continue waiting for more input. After writing to stdin, the stream should be explicitly dropped or closed. Consider adding drop(child.stdin.take()); after line 96 to ensure the stream is closed before waiting for output.

Suggested change
child.stdin.as_mut().unwrap().write_all(input.as_bytes())?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes())?;
}

Copilot uses AI. Check for mistakes.
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable path.

Suggested change
const path = require('path');

Copilot uses AI. Check for mistakes.
@ajag408 ajag408 requested a review from raiseerco January 17, 2026 06:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants