Skip to content

Commit d044b7e

Browse files
committed
feat(policy): policy-as-code 门禁 .shellward.json (响应 issue #2) v0.7.21
- .shellward.json: failOn(类别/严重度)/maxFindings/allowOverseas 声明CI门禁 - scan --ci 据策略判定,无文件时默认有critical即失败 - 策略在push声明→运行时执行的纵深防御(外部开发者建议) - src/compliance/policy.ts + 10项测试; 全套328
1 parent 19067ae commit d044b7e

6 files changed

Lines changed: 175 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ 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.7.21] - 2026-06-23
9+
10+
### Added — policy-as-code 门禁(响应 issue #2,首个外部需求)
11+
- 项目根放 **`.shellward.json`** 声明 CI 门禁规则:`failOn`(类别/严重度命中即失败)、`maxFindings`(发现数上限)、`allowOverseas`(豁免指定境外厂商)
12+
- `shellward scan --ci` 据策略判定通过/失败并打印违规项;无策略文件时默认「有 critical 即失败」
13+
- 实现「策略在 Git push 时声明 → 运行时执行」的纵深防御(外部开发者 cschanhniem 的建议)
14+
- `src/compliance/policy.ts` + 10 项策略测试;全套 **328 测试**全绿
15+
816
## [0.7.20] - 2026-06-23
917

1018
### Added — web 端中英文双语

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
[![npm](https://img.shields.io/npm/v/shellward?color=cb0000&label=npm)](https://www.npmjs.com/package/shellward)
1010
[![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
11-
[![tests](https://img.shields.io/badge/tests-318%20passing-brightgreen)](#performance)
11+
[![tests](https://img.shields.io/badge/tests-328%20passing-brightgreen)](#performance)
1212
[![deps](https://img.shields.io/badge/dependencies-0-brightgreen)](#performance)
1313

1414
**🌐 官网: https://jnmetacode.github.io/shellward/**
@@ -221,6 +221,24 @@ jobs:
221221
222222
Or run it directly without the Action: `npx shellward scan --ci`.
223223

224+
### Policy-as-code (`.shellward.json`)
225+
226+
声明式 CI 门禁([issue #2](https://github.com/jnMetaCode/shellward/issues/2))— put a `.shellward.json` in your repo root:
227+
228+
```json
229+
{
230+
"failOn": ["secret", "pii"],
231+
"maxFindings": 0,
232+
"allowOverseas": ["OpenAI"]
233+
}
234+
```
235+
236+
- `failOn` — fail CI if any finding matches these **kinds** (`secret`/`pii`/`overseas`/`env-perm`) or **severities** (`critical`/`high`/`medium`)
237+
- `maxFindings` — max total findings allowed
238+
- `allowOverseas` — overseas providers explicitly permitted (exempt from failure)
239+
240+
`shellward scan --ci` reads it; without the file it defaults to "fail on any critical". 实现「策略在 Git push 时声明 → 运行时执行」的纵深防御。
241+
224242
## 8-Layer Defense
225243

226244
```

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.7.20",
3+
"version": "0.7.21",
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": [

src/cli.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ShellWard } from './core/engine.js'
1818
import { runProjectComplianceAudit } from './compliance/audit.js'
1919
import { renderComplianceReport, renderProjectFindings } from './compliance/report.js'
2020
import { renderHtmlReport } from './compliance/html-report.js'
21+
import { checkPolicy } from './compliance/policy.js'
2122
import { runInit } from './init.js'
2223
import { resolveLocale } from './types.js'
2324

@@ -162,10 +163,16 @@ function runScan(args: string[]) {
162163
}
163164
}
164165

165-
// CI 模式:有 critical 项目发现则非零退出
166+
// CI 模式:按 .shellward.json 策略门禁(无策略文件则默认"有 critical 即失败")
166167
if (ci) {
167-
const criticals = scan.findings.filter(f => f.severity === 'critical').length
168-
if (criticals > 0) process.exit(1)
168+
const pol = checkPolicy(scan, root)
169+
if (!json) {
170+
process.stdout.write(zh
171+
? `\n🔒 策略门禁(${pol.source === 'file' ? '.shellward.json' : '默认'}):${pol.pass ? '✅ 通过' : '❌ 未通过'}\n`
172+
: `\n🔒 Policy gate (${pol.source === 'file' ? '.shellward.json' : 'default'}): ${pol.pass ? '✅ pass' : '❌ fail'}\n`)
173+
for (const v of pol.violations) process.stdout.write(` - ${v}\n`)
174+
}
175+
if (!pol.pass) process.exit(1)
169176
}
170177
}
171178

src/compliance/policy.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// src/compliance/policy.ts — policy-as-code 门禁(响应 GitHub issue #2)
2+
//
3+
// 在 Git/CI 边界用声明式策略约束扫描结果:项目根放 `.shellward.json`,
4+
// CI(shellward scan --ci)据此判定通过/失败。把"策略在 push 时声明 → 运行时执行"
5+
// 的纵深防御补上 push 这一端。无策略文件时回退到默认(有 critical 即失败)。
6+
//
7+
// 示例 .shellward.json:
8+
// {
9+
// "failOn": ["secret", "pii"], // 命中这些"类别"或"严重度"即失败
10+
// "maxFindings": 0, // 总发现数上限
11+
// "allowOverseas": ["OpenAI"] // 允许的境外厂商(不计入失败)
12+
// }
13+
14+
import { readFileSync } from 'fs'
15+
import { join } from 'path'
16+
import type { ProjectScanResult, ProjectFinding, FindingKind } from './project-scan.js'
17+
18+
export interface ShellwardPolicy {
19+
/** 命中即失败:可填类别(secret/pii/overseas/env-perm) 或 严重度(critical/high/medium) */
20+
failOn?: string[]
21+
/** 总发现数上限(含被 allowOverseas 豁免后的) */
22+
maxFindings?: number
23+
/** 允许的境外大模型厂商(provider 名,命中这些的 overseas 发现被豁免) */
24+
allowOverseas?: string[]
25+
}
26+
27+
export interface PolicyResult {
28+
pass: boolean
29+
source: 'file' | 'default'
30+
violations: string[]
31+
policy: ShellwardPolicy
32+
}
33+
34+
const KINDS: FindingKind[] = ['overseas', 'secret', 'pii', 'env-perm']
35+
const SEVERITIES = ['critical', 'high', 'medium']
36+
37+
/** 读取项目根的 .shellward.json;无/坏则返回默认策略(有 critical 即失败) */
38+
export function loadPolicy(root: string): { policy: ShellwardPolicy; source: 'file' | 'default' } {
39+
try {
40+
const raw = readFileSync(join(root, '.shellward.json'), 'utf-8')
41+
const p = JSON.parse(raw)
42+
if (p && typeof p === 'object') return { policy: sanitize(p), source: 'file' }
43+
} catch { /* 无策略文件或解析失败 → 默认 */ }
44+
return { policy: { failOn: ['critical'] }, source: 'default' }
45+
}
46+
47+
function sanitize(p: any): ShellwardPolicy {
48+
const out: ShellwardPolicy = {}
49+
if (Array.isArray(p.failOn)) out.failOn = p.failOn.filter((x: any) => typeof x === 'string')
50+
if (typeof p.maxFindings === 'number' && p.maxFindings >= 0) out.maxFindings = Math.floor(p.maxFindings)
51+
if (Array.isArray(p.allowOverseas)) out.allowOverseas = p.allowOverseas.filter((x: any) => typeof x === 'string')
52+
return out
53+
}
54+
55+
/** 把被 allowOverseas 豁免的 overseas 发现去掉 */
56+
function effective(findings: ProjectFinding[], allow: string[]): ProjectFinding[] {
57+
if (!allow.length) return findings
58+
const allowLower = new Set(allow.map(a => a.toLowerCase()))
59+
return findings.filter(f => {
60+
if (f.kind !== 'overseas') return true
61+
const prov = (f.provider_en || f.provider_zh || '').toLowerCase()
62+
return !allowLower.has(prov)
63+
})
64+
}
65+
66+
/** 根据策略评估扫描结果,返回是否通过 + 违规说明 */
67+
export function evaluatePolicy(scan: ProjectScanResult, policy: ShellwardPolicy): PolicyResult {
68+
const allow = policy.allowOverseas || []
69+
const findings = effective(scan.findings, allow)
70+
const violations: string[] = []
71+
72+
const failOn = policy.failOn || []
73+
for (const token of failOn) {
74+
if (KINDS.includes(token as FindingKind)) {
75+
const n = findings.filter(f => f.kind === token).length
76+
if (n > 0) violations.push(`failOn "${token}": 命中 ${n} 项`)
77+
} else if (SEVERITIES.includes(token)) {
78+
const n = findings.filter(f => f.severity === token).length
79+
if (n > 0) violations.push(`failOn 严重度 "${token}": 命中 ${n} 项`)
80+
}
81+
}
82+
83+
if (typeof policy.maxFindings === 'number' && findings.length > policy.maxFindings) {
84+
violations.push(`发现数 ${findings.length} 超过上限 maxFindings=${policy.maxFindings}`)
85+
}
86+
87+
return { pass: violations.length === 0, source: 'default', violations, policy }
88+
}
89+
90+
/** 加载 + 评估的便捷封装 */
91+
export function checkPolicy(scan: ProjectScanResult, root: string): PolicyResult {
92+
const { policy, source } = loadPolicy(root)
93+
const r = evaluatePolicy(scan, policy)
94+
r.source = source
95+
return r
96+
}

test-compliance.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { renderComplianceReport, renderProjectFindings } from './src/compliance/
1313
import { scanProject } from './src/compliance/project-scan'
1414
import { renderHtmlReport } from './src/compliance/html-report'
1515
import { validateRepoUrl } from './src/web/scan-server'
16+
import { evaluatePolicy, loadPolicy } from './src/compliance/policy'
1617
import { suggestDomestic, DOMESTIC_MODELS } from './src/rules/domestic-alternatives'
1718
import { COMPLIANCE_CONTROLS } from './src/compliance/regulations'
1819
import { DEFAULT_CONFIG } from './src/types'
@@ -397,6 +398,46 @@ console.log('\n--- Markdown 策略 + 诚实评分 ---')
397398
} finally { rmSync(clean, { recursive: true, force: true }) }
398399
}
399400

401+
// === 14. policy-as-code 门禁(issue #2)===
402+
console.log('\n--- 策略门禁 .shellward.json ---')
403+
{
404+
const mk = (kind: any, sev: any, provider?: string) => ({ kind, file: 'a.ts', detail: 'x', severity: sev, provider_en: provider } as any)
405+
const scanOf = (findings: any[]) => ({ root: '/x', filesScanned: 1, truncated: false, findings, counts: { overseas: 0, secret: 0, pii: 0, 'env-perm': 0 } } as any)
406+
407+
// failOn 类别
408+
const r1 = evaluatePolicy(scanOf([mk('secret', 'critical')]), { failOn: ['secret'] })
409+
test('failOn secret 命中 → 不通过', !r1.pass)
410+
const r2 = evaluatePolicy(scanOf([mk('overseas', 'high', 'OpenAI')]), { failOn: ['secret'] })
411+
test('failOn secret 无密钥 → 通过', r2.pass)
412+
413+
// failOn 严重度
414+
const r3 = evaluatePolicy(scanOf([mk('secret', 'critical')]), { failOn: ['critical'] })
415+
test('failOn critical 命中 → 不通过', !r3.pass)
416+
417+
// allowOverseas 豁免
418+
const r4 = evaluatePolicy(scanOf([mk('overseas', 'high', 'OpenAI')]), { failOn: ['overseas'], allowOverseas: ['OpenAI'] })
419+
test('allowOverseas 豁免该厂商 → 通过', r4.pass)
420+
const r5 = evaluatePolicy(scanOf([mk('overseas', 'high', 'Anthropic Claude')]), { failOn: ['overseas'], allowOverseas: ['OpenAI'] })
421+
test('allowOverseas 不含的厂商 → 不通过', !r5.pass)
422+
423+
// maxFindings
424+
const r6 = evaluatePolicy(scanOf([mk('pii', 'high'), mk('pii', 'high')]), { maxFindings: 1 })
425+
test('maxFindings 超标 → 不通过', !r6.pass)
426+
const r7 = evaluatePolicy(scanOf([]), { failOn: ['secret', 'critical'], maxFindings: 0 })
427+
test('干净项目 → 通过', r7.pass)
428+
429+
// loadPolicy 默认 + 文件
430+
const dir = mkdtempSync(join(tmpdir(), 'sw-pol-'))
431+
try {
432+
test('无文件 → 默认策略(failOn critical)', loadPolicy(dir).source === 'default')
433+
writeFileSync(join(dir, '.shellward.json'), '{"failOn":["pii"],"maxFindings":5}')
434+
const lp = loadPolicy(dir)
435+
test('有文件 → 读取策略', lp.source === 'file' && lp.policy.maxFindings === 5)
436+
writeFileSync(join(dir, '.shellward.json'), '{bad json')
437+
test('坏文件 → 回退默认(不崩溃)', loadPolicy(dir).source === 'default')
438+
} finally { rmSync(dir, { recursive: true, force: true }) }
439+
}
440+
400441
// === 总结 ===
401442
console.log(`\n========== 结果: ${passed} 通过, ${failed} 失败 ==========\n`)
402443
if (failed > 0) process.exit(1)

0 commit comments

Comments
 (0)