Skip to content

Commit 6bf33e5

Browse files
committed
fix: v0.6.1 — fail-safe input coercion across public engine methods
Security checks now coerce null/non-string/garbage input instead of throwing (found by adversarial QA pass; regression tests added). See CHANGELOG.
1 parent d682cda commit 6bf33e5

5 files changed

Lines changed: 49 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/),
66
and this project adheres to [Semantic Versioning](https://semver.org/).
77

8+
## [0.6.1] - 2026-06-05
9+
10+
### Fixed
11+
- **Input robustness (fail-safe)**: every public engine method (`checkCommand`, `checkInjection`, `scanData`, `checkTool`, `checkPath`, `checkResponse`, `checkAction`, `checkOutbound`, `scanToolDefinition`, `extractTextFields`) now coerces hostile/garbage input (`null`, `undefined`, numbers, objects) instead of throwing — a security check must never crash on the input it inspects. Found by an adversarial QA pass; locked in with regression tests.
12+
813
## [0.6.0] - 2026-06-05
914

1015
### Added

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "shellward",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"mcpName": "io.github.jnMetaCode/shellward",
55
"description": "AI agent security & MCP security middleware — prompt injection detection, AI firewall, runtime guardrails & data-loss prevention for LLM tool calls. 8-layer defense against data exfiltration & dangerous commands. Zero dependencies. SDK + OpenClaw plugin. Supports LangChain, AutoGPT, Claude Code, Cursor, OpenAI Agents, Hermes Agent.",
66
"keywords": [

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
"url": "https://github.com/jnMetaCode/shellward",
77
"source": "github"
88
},
9-
"version": "0.6.0",
9+
"version": "0.6.1",
1010
"packages": [
1111
{
1212
"registryType": "npm",
1313
"identifier": "shellward",
14-
"version": "0.6.0",
14+
"version": "0.6.1",
1515
"runtime": "node",
1616
"transport": {
1717
"type": "stdio"

src/core/engine.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ export class ShellWard {
256256
// ========== L2: Data Scanner ==========
257257

258258
scanData(text: string, toolName?: string): ScanResult {
259+
text = asString(text)
259260
const [, findings] = redactSensitive(text, this.customSensitive)
260261
const hasSensitiveData = findings.length > 0
261262
const summary = findings.map(f => `${f.name}(${f.count})`).join(', ')
@@ -282,7 +283,7 @@ export class ShellWard {
282283
// ========== L3: Tool & Command Checker ==========
283284

284285
checkTool(toolName: string): CheckResult {
285-
const toolLower = toolName.toLowerCase()
286+
const toolLower = asString(toolName).toLowerCase()
286287
const enforce = this.config.mode === 'enforce'
287288

288289
// allowedTools always wins — user-trusted tools bypass policy.
@@ -317,7 +318,7 @@ export class ShellWard {
317318

318319
checkCommand(cmd: string, toolName?: string): CheckResult {
319320
const enforce = this.config.mode === 'enforce'
320-
const parts = splitCommands(cmd)
321+
const parts = splitCommands(asString(cmd))
321322

322323
for (const part of parts) {
323324
// Normalize shell-quote obfuscation (e.g. r''m / r""m → rm) before matching.
@@ -346,6 +347,7 @@ export class ShellWard {
346347
}
347348

348349
checkPath(path: string, operation: 'write' | 'delete', toolName?: string): CheckResult {
350+
path = asString(path)
349351
const enforce = this.config.mode === 'enforce'
350352
const normalizedPath = normalizePath(path)
351353

@@ -372,6 +374,7 @@ export class ShellWard {
372374
// ========== L4: Injection Detection ==========
373375

374376
checkInjection(text: string, options?: { source?: string; threshold?: number }): InjectionResult {
377+
text = asString(text)
375378
const threshold = options?.threshold ?? this.config.injectionThreshold
376379
const enforce = this.config.mode === 'enforce'
377380

@@ -430,6 +433,7 @@ export class ShellWard {
430433
// the SDK, the MCP server, or at plugin tool-discovery time.
431434

432435
scanToolDefinition(tool: McpToolDefinition, options?: { threshold?: number }): ToolPoisoningResult {
436+
tool = (tool && typeof tool === 'object') ? tool : { name: 'unknown' }
433437
const threshold = options?.threshold ?? 40
434438
const findings: ToolPoisoningFinding[] = []
435439
let score = 0
@@ -507,6 +511,8 @@ export class ShellWard {
507511
// ========== L5: Security Gate ==========
508512

509513
checkAction(action: string, details: string): CheckResult {
514+
action = asString(action)
515+
details = asString(details)
510516
if (action === 'exec' || action === 'shell') {
511517
return this.checkCommand(details)
512518
}
@@ -550,6 +556,7 @@ export class ShellWard {
550556
// ========== L6: Response Checker ==========
551557

552558
checkResponse(content: string): ResponseCheckResult {
559+
content = asString(content)
553560
const canaryLeak = this._canaryToken ? content.includes(this._canaryToken) : false
554561

555562
if (canaryLeak) {
@@ -638,7 +645,8 @@ export class ShellWard {
638645
}
639646

640647
checkOutbound(toolName: string, params: Record<string, any>): CheckResult {
641-
const toolLower = toolName.toLowerCase()
648+
params = (params && typeof params === 'object') ? params : {}
649+
const toolLower = asString(toolName).toLowerCase()
642650
const isOutbound = this.outboundTools.has(toolLower)
643651
const isDualUse = DUAL_USE_TOOLS.has(toolLower)
644652
const enforce = this.config.mode === 'enforce'
@@ -757,6 +765,7 @@ export class ShellWard {
757765

758766
extractTextFields(args: Record<string, any>): string[] {
759767
const results: string[] = []
768+
if (!args || typeof args !== 'object') return results
760769
for (const field of TEXT_FIELDS) {
761770
if (typeof args[field] === 'string' && args[field].length > 0) {
762771
results.push(args[field])
@@ -841,6 +850,17 @@ function truncate(s: string, max: number): string {
841850
return s.length > max ? s.slice(0, max) + '...' : s
842851
}
843852

853+
/**
854+
* Defensive coercion at public API boundaries: a security check must fail safe
855+
* on hostile/garbage input, never throw. null/undefined → '', everything else
856+
* is stringified.
857+
*/
858+
function asString(v: unknown): string {
859+
if (typeof v === 'string') return v
860+
if (v == null) return ''
861+
try { return String(v) } catch { return '' }
862+
}
863+
844864
/**
845865
* Defeat shell-quote obfuscation for DETECTION (not execution): strip empty
846866
* quote pairs so `r''m -rf /` and `r""m -rf /` normalize to `rm -rf /`.

test-sdk.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,24 @@ console.log('\n--- 自定义规则 ---')
295295
test('非法自定义正则被跳过而非崩溃', constructed)
296296
}
297297

298+
// === 健壮性:对垃圾输入必须 fail-safe(不抛异常)===
299+
console.log('\n--- 输入健壮性 ---')
300+
{
301+
const g = new ShellWard({ locale: 'zh' })
302+
const junk: any[] = [null, undefined, 0, NaN, {}, [], 12345, true]
303+
let threw = false
304+
for (const v of junk) {
305+
try {
306+
g.checkCommand(v); g.checkInjection(v); g.scanData(v); g.checkTool(v)
307+
g.checkPath(v, 'delete'); g.checkResponse(v); g.checkAction(v, v)
308+
g.scanToolDefinition(v); g.checkOutbound(v, v); g.extractTextFields(v)
309+
} catch { threw = true }
310+
}
311+
test('所有公共方法对 null/非字符串输入不抛异常', !threw)
312+
test('null 输入按安全默认处理(checkCommand 放行空)', g.checkCommand(null as any).allowed)
313+
test('null injection 安全', g.checkInjection(null as any).safe)
314+
}
315+
298316
// === Summary ===
299317
console.log('\n========================================')
300318
console.log(` SDK 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed} 项)`)

0 commit comments

Comments
 (0)