-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Add language-agnostic JSON interface and CLI for cross-language Shield usage #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: Add language-agnostic JSON interface and CLI for cross-language Shield usage #2
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this 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.
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
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.
|
|
||
| const MAX_INPUT_SIZE = 100 * 1024; // 100KB | ||
|
|
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
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.
| const MAX_INPUT_SIZE = 100 * 1024; // 100KB | |
| import { MAX_INPUT_SIZE } from './handler'; |
raiseerco
left a comment
There was a problem hiding this 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
… and security attestations
There was a problem hiding this 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'); |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
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.
| process.stdout.write(JSON.stringify(errorResponse) + '\n'); | |
| process.stdout.write(JSON.stringify(errorResponse) + '\n'); | |
| process.stdin.destroy(); |
| } catch (e) { | ||
| // SECURITY: Never expose internal error details in production | ||
| return JSON.stringify( | ||
| errorResponse( | ||
| 'INTERNAL_ERROR', | ||
| 'An unexpected error occurred', | ||
| requestHash, | ||
| ), | ||
| ); | ||
| } |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
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.
|
|
||
| - name: Rename binary (Unix) | ||
| if: matrix.platform != 'win32' | ||
| run: mv shield-${{ matrix.platform }}-* shield-${{ matrix.platform }}-${{ matrix.arch }} |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
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.
| 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 }}" |
| result = subprocess.run( | ||
| [shield_path], | ||
| input=json.dumps(request), | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
|
|
||
| response = json.loads(result.stdout) | ||
|
|
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
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.
| 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}", | |
| ) |
| .stdout(Stdio::piped()) | ||
| .spawn()?; | ||
|
|
||
| child.stdin.as_mut().unwrap().write_all(input.as_bytes())?; |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
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.
| child.stdin.as_mut().unwrap().write_all(input.as_bytes())?; | |
| { | |
| if let Some(mut stdin) = child.stdin.take() { | |
| stdin.write_all(input.as_bytes())?; | |
| } | |
| } |
| 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); |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
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().
| # SECURITY: Run audit before building | ||
| - name: Security audit | ||
| run: pnpm audit --audit-level=high | ||
| continue-on-error: true |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
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.
| continue-on-error: true |
| .stdout(Stdio::piped()) | ||
| .spawn()?; | ||
|
|
||
| child.stdin.as_mut().unwrap().write_all(input.as_bytes())?; |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
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.
| 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())?; |
| .stdout(Stdio::piped()) | ||
| .spawn()?; | ||
|
|
||
| child.stdin.as_mut().unwrap().write_all(input.as_bytes())?; |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
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.
| child.stdin.as_mut().unwrap().write_all(input.as_bytes())?; | |
| if let Some(mut stdin) = child.stdin.take() { | |
| stdin.write_all(input.as_bytes())?; | |
| } |
| const { execSync } = require('child_process'); | ||
| const fs = require('fs'); | ||
| const os = require('os'); | ||
| const path = require('path'); |
Copilot
AI
Jan 17, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused variable path.
| const path = require('path'); |
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:
What's Added
Core JSON Interface
src/json/types.tssrc/json/schema.tssrc/json/handler.tssrc/json/handler.test.tssrc/cli.tsStandalone Binary Support (SEA)
sea-config.jsonscripts/build-sea.shscripts/build-sea.ps1.github/workflows/release.ymlIntegration Examples
examples/python/examples/go/examples/rust/Documentation
docs/integration-python.mddocs/integration-go.mddocs/integration-rust.mdexamples/README.mdBinary Releases
Pre-built binaries for all platforms (~87MB each, includes Node.js runtime):
shield-linux-x64shield-darwin-arm64shield-darwin-x64shield-windows-x64.exeSecurity Enhancements
pnpm auditruns before binary buildsJSON 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
Testing
Breaking Changes
None. This is purely additive.
Checklist
pnpm test)pnpm build)pnpm build:binary)