focus-guard 0.0.0-stage → 3.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/README.zh-CN.md +98 -0
- package/docs/LEGISLATION-LAW.md +102 -0
- package/docs/MASTER-PLAN-2.0.0.md +133 -0
- package/docs/MASTER-PLAN-3.0.0.md +76 -0
- package/docs/RULES.md +416 -0
- package/docs/deepseek-pricing-audit.md +63 -0
- package/hooks/guard.mjs +1142 -0
- package/hooks/hooks.json +76 -0
- package/package.json +61 -4
- package/skills/focus-thinking/SKILL.md +299 -0
- package/src/adapters/dsh/eligibility-gate.mjs +302 -0
- package/src/adapters/dsh/fg-appeal-tool.mjs +47 -0
- package/src/adapters/dsh/fg-apply-tool.mjs +68 -0
- package/src/adapters/dsh/protocol.mjs +42 -0
- package/src/core/approval.mjs +64 -0
- package/src/core/audit.mjs +71 -0
- package/src/core/backup.mjs +55 -0
- package/src/core/checkEligibility.mjs +271 -0
- package/src/core/constants.mjs +80 -0
- package/src/core/decisionEngine.mjs +45 -0
- package/src/core/env.mjs +113 -0
- package/src/core/grants.mjs +63 -0
- package/src/core/profileLoader.mjs +122 -0
- package/src/core/redlines.mjs +238 -0
- package/src/core/risk.mjs +75 -0
- package/src/core/session.mjs +9 -0
- package/src/core/state.mjs +265 -0
- package/src/dsh/audit.mjs +192 -0
- package/src/dsh/compactionWatch.mjs +407 -0
- package/src/dsh/cordis.patch.yml +10 -0
- package/src/dsh/heartbeat.mjs +128 -0
- package/src/dsh/index.js +146 -0
- package/src/dsh/pipeline.mjs +908 -0
- package/src/dsh/postProgress.mjs +362 -0
- package/src/dsh/preGuard.mjs +288 -0
- package/src/dsh/seams.mjs +454 -0
- package/src/dsh/selftest.mjs +463 -0
- package/src/dsh/sentinel.py +160 -0
- package/src/dsh/status.mjs +73 -0
- package/src/dsh/stopGuard.mjs +259 -0
- package/src/dsh/toolName.mjs +41 -0
- package/src/dsh/tools.mjs +115 -0
- package/src/dsh/workspace.mjs +145 -0
- package/src/peak-cost.mjs +115 -0
- package/src/profiles/deepseek-flash.mjs +24 -0
- package/src/profiles/deepseek-pro.mjs +25 -0
- package/src/profiles/default.mjs +26 -0
- package/src/profiles/glm.mjs +26 -0
- package/src/profiles/gpt-astra.mjs +30 -0
- package/tools/audit-chain.mjs +105 -0
- package/tools/env-fingerprint.mjs +88 -0
- package/tools/library-build.mjs +222 -0
- package/tools/sentinel.mjs +121 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// FocusGuard 母版层 · 画像加载器(3.0.5)
|
|
2
|
+
//
|
|
3
|
+
// 职责:① modelId → 画像对象;② 层 → 子开关映射;③ 开关判定;④ 未实现开关登记。
|
|
4
|
+
// 纪律:**只做数据查表**,禁止按 modelId 做全等判断的硬编码分支;
|
|
5
|
+
// 匹配不到一律回落 default 画像(最严治理)。
|
|
6
|
+
// 层→开关的拆分依据:领导 2026-10-09 批复——L1 拆成 stalledFuse 与 budgetGate 两个子判据;
|
|
7
|
+
// reasoningWatch / emotionFilter 只登记、不绑定执行(trace 报 skipped 并注明原因)。
|
|
8
|
+
|
|
9
|
+
import defaultProfile from '../profiles/default.mjs'
|
|
10
|
+
import deepseekFlash from '../profiles/deepseek-flash.mjs'
|
|
11
|
+
import deepseekPro from '../profiles/deepseek-pro.mjs'
|
|
12
|
+
import glm from '../profiles/glm.mjs'
|
|
13
|
+
import gptAstra from '../profiles/gpt-astra.mjs'
|
|
14
|
+
|
|
15
|
+
/** 画像注册表:画像 id → 画像对象。新增画像 = 加 profiles/ 文件 + 在此登记一行。 */
|
|
16
|
+
export const PROFILES = {
|
|
17
|
+
'deepseek-flash': deepseekFlash,
|
|
18
|
+
'deepseek-pro': deepseekPro,
|
|
19
|
+
'glm': glm,
|
|
20
|
+
'gpt-astra': gptAstra,
|
|
21
|
+
'default': defaultProfile,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 显式别名表:真实模型名 → 画像 id。这是**数据**,不是分支。
|
|
26
|
+
* 已知真实 modelId 时在此登记一行即可,无需改任何逻辑。
|
|
27
|
+
*/
|
|
28
|
+
export const PROFILE_ALIASES = {
|
|
29
|
+
// 例:'deepseek-v4.1-flash': 'deepseek-flash',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 层 → 子开关名。与 checkEligibility 的六层对齐;null 表示该层不受画像控制、永不跳过。
|
|
34
|
+
* L0 申请完整性 / L5 语义探针(由调用方是否注入 model 决定)/ L6 授权:均无画像开关。
|
|
35
|
+
*/
|
|
36
|
+
export const LAYER_SWITCHES = {
|
|
37
|
+
'0': null,
|
|
38
|
+
'1-fuse': 'stalledFuse',
|
|
39
|
+
'1-budget': 'budgetGate',
|
|
40
|
+
'2': 'redlineGate',
|
|
41
|
+
'3': 'approvalGate',
|
|
42
|
+
'4': 'evidenceGate',
|
|
43
|
+
'5': null,
|
|
44
|
+
'6': null,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 已登记但引擎尚未实现的开关:只进 trace 的 skipped 报告,不参与执行。 */
|
|
48
|
+
export const UNIMPLEMENTED_SWITCHES = {
|
|
49
|
+
reasoningWatch: 'no engine implementation, TODO',
|
|
50
|
+
emotionFilter: 'no engine implementation, TODO',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** modelId 归一化:去空白 + 小写。 */
|
|
54
|
+
export function normalizeModelId(modelId) {
|
|
55
|
+
return String(modelId ?? '').trim().toLowerCase()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 画像**不可关闭**的开关:审批层(L3)是最后防线。
|
|
60
|
+
* 若允许画像关掉它,则"decide 跳过 L3 + 闸只认授权表"会把审批端到端绕过
|
|
61
|
+
* (HANDOFF §十 安全项,子代理实测)。加载时强制启用并留痕,不静默接受。
|
|
62
|
+
*/
|
|
63
|
+
export const FORCED_ON_SWITCHES = ['approvalGate']
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 落地画像:浅拷贝 + 标注来源 + 强制不可关闭的开关保持启用。
|
|
67
|
+
*/
|
|
68
|
+
function enforcePolicy(base, source) {
|
|
69
|
+
const profile = { ...base, profileSource: source }
|
|
70
|
+
for (const key of FORCED_ON_SWITCHES) {
|
|
71
|
+
const entry = profile[key]
|
|
72
|
+
if (entry && typeof entry === 'object' && entry.enabled === false) {
|
|
73
|
+
console.warn(
|
|
74
|
+
`[focus-guard] 画像「${profile.id}」试图关闭 ${key} → 已强制启用(审批层不可关,见 HANDOFF §十 安全项)`,
|
|
75
|
+
)
|
|
76
|
+
profile[key] = { ...entry, enabled: true, forcedOn: true }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return profile
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* modelId → 画像对象(浅拷贝 + 标注来源,避免调用方改到画像本体)。
|
|
84
|
+
* 匹配顺序:空值 → 显式别名 → 画像 id 精确 → 画像 id 前缀 → default 兜底。
|
|
85
|
+
*/
|
|
86
|
+
export function loadProfile(modelId) {
|
|
87
|
+
const id = normalizeModelId(modelId)
|
|
88
|
+
if (!id) return enforcePolicy(defaultProfile, 'default:empty-model-id')
|
|
89
|
+
|
|
90
|
+
const alias = PROFILE_ALIASES[id]
|
|
91
|
+
if (alias && PROFILES[alias]) return enforcePolicy(PROFILES[alias], `alias:${id}`)
|
|
92
|
+
|
|
93
|
+
if (PROFILES[id]) return enforcePolicy(PROFILES[id], `exact:${id}`)
|
|
94
|
+
|
|
95
|
+
const prefixed = Object.keys(PROFILES).find((key) => key !== 'default' && id.startsWith(key))
|
|
96
|
+
if (prefixed) return enforcePolicy(PROFILES[prefixed], `prefix:${prefixed}`)
|
|
97
|
+
|
|
98
|
+
return enforcePolicy(defaultProfile, `default:unmatched:${id}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 该层是否启用。无开关(null)、画像缺字段、字段非对象 → 一律 true(保守优先,不误跳过)。
|
|
103
|
+
*/
|
|
104
|
+
export function layerEnabled(profile, layerKey) {
|
|
105
|
+
const switchName = LAYER_SWITCHES[layerKey]
|
|
106
|
+
if (!switchName) return true
|
|
107
|
+
const entry = profile && profile[switchName]
|
|
108
|
+
if (!entry || typeof entry !== 'object') return true
|
|
109
|
+
return entry.enabled !== false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 未实现开关清单(供 trace 的 skipped 报告)。 */
|
|
113
|
+
export function unimplementedSwitches(profile) {
|
|
114
|
+
const out = []
|
|
115
|
+
for (const [name, reason] of Object.entries(UNIMPLEMENTED_SWITCHES)) {
|
|
116
|
+
const entry = profile && profile[name]
|
|
117
|
+
if (entry && typeof entry === 'object') {
|
|
118
|
+
out.push({ name, enabled: entry.enabled !== false, reason })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// FocusGuard 母版层 · 绝对红线(v3.0.5;《资料与代码分层总规范》二·1)
|
|
2
|
+
// 高危命令特征库与判定:六类破坏性命令,完全访问下仍须人类实时审批。
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const MUTATOR_HEAD_RE = /^(?:rm|rmdir|rd|mv|del|cp|copy|xcopy|robocopy|install|rsync|chmod|chown|kill|taskkill|truncate|mkfs|mkdir|touch|tee|patch|git\s+(?:add|commit|push|pull|merge|rebase|reset|checkout|clean|restore|apply|stash|mv|rm)|npm\s+(?:i|install|ci|uninstall|remove|rm|update|publish)|pnpm\s+(?:add|install|remove|rm|update|publish)|yarn\s+(?:add|install|remove|publish|global)|pip3?\s+(?:install|uninstall)|(?:Set|Add|Remove|New|Copy|Move|Clear)-Content|New-Item|Copy-Item|Move-Item|Remove-Item|Set-ItemProperty|New-ItemProperty|Out-File|sed\s+[^&|;]*-i)\b/i;
|
|
6
|
+
export const CMD_PREFIX_RE = /^(?:sudo|doas|time|nohup|nice|env|xargs|start|call|command|builtin)\s+(?:-[^\s]+\s+|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*/i;
|
|
7
|
+
export function isMutatingBashCmd(cmd) {
|
|
8
|
+
for (const seg of String(cmd || "").split(/[;&|]+/)) {
|
|
9
|
+
let s = seg.trim();
|
|
10
|
+
for (let i = 0; i < 3; i++) {
|
|
11
|
+
const next = s.replace(CMD_PREFIX_RE, "");
|
|
12
|
+
if (next === s) break;
|
|
13
|
+
s = next;
|
|
14
|
+
}
|
|
15
|
+
if (MUTATOR_HEAD_RE.test(s)) return true;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
export const FILE_REDIRECT_RE = /(^|\s)>{1,2}(?!\s*&)/;
|
|
20
|
+
// 2.4.0 高危命令特征库(六类:破坏性删除/强制推送与历史覆盖/系统权限与配置篡改/全局依赖安装/对外发送与发布/数据库影响)
|
|
21
|
+
// 即使完全访问(yolo)也须人类实时审批;普通单文件 rm、常规构建不在此列。
|
|
22
|
+
// 2.5.2 加固:补别名与长旗标写法(npm i -g / yarn global add / pnpm add -g / rd /s / ri -r /
|
|
23
|
+
// wget --post-data / Invoke-WebRequest -Method POST / curl -d),并修掉两处误伤
|
|
24
|
+
// (`--force` 里的字母 r 被当成递归删除;`--dry-run` 只在紧跟子命令时才豁免)。
|
|
25
|
+
export const DANGEROUS_PATTERNS = new RegExp([
|
|
26
|
+
// 一、破坏性删除:递归旗标必须是旗标本身(-r/-rf/-fr/-R/--recursive),不再用会命中 --force 里 r 的 "-\\w*r"
|
|
27
|
+
"(?:sudo\\s+)?\\brm\\b[^&|;]*\\s(?:-[a-zA-Z]*[rR][a-zA-Z]*\\b|--recursive\\b)",
|
|
28
|
+
"(?:^|[;&|]\\s*)(?:rmdir|rd)\\b[^&|;]*/s",
|
|
29
|
+
"\\bdel\\b[^&|;]*/[fsq]",
|
|
30
|
+
"Remove-Item\\s[^&|;]*-Recurse",
|
|
31
|
+
"(?:^|[;&|]\\s*)ri\\b[^&|;]*\\s(?:-[a-zA-Z]*[rR][a-zA-Z]*\\b|-Recurse\\b)",
|
|
32
|
+
"shutil\\.rmtree",
|
|
33
|
+
"\\brmSync\\s*\\([^&|;]*recursive",
|
|
34
|
+
"\\brmdirSync\\s*\\([^&|;]*recursive",
|
|
35
|
+
"drop\\s+table",
|
|
36
|
+
"drop\\s+database",
|
|
37
|
+
"truncate\\s+table",
|
|
38
|
+
// 二、系统权限与配置篡改
|
|
39
|
+
"chmod\\s+[^&|;]*\\b777\\b",
|
|
40
|
+
"chmod\\s+-R",
|
|
41
|
+
"\\bchown\\b",
|
|
42
|
+
"\\breg\\s+(?:add|delete)\\b",
|
|
43
|
+
"\\bnet\\s+user\\b.*\\b(?:add|delete)\\b",
|
|
44
|
+
// 三、全局依赖安装:npm/pnpm 的 i|install|add|uninstall 与 -g/--global 任意位置;yarn global 无 -g 也拦
|
|
45
|
+
"\\b(?:npm|pnpm)\\s+(?:i|install|add|uninstall|remove|rm)\\b[^&|;]*(?:\\s-g(?![\\w-])|\\s--global(?![\\w-]))",
|
|
46
|
+
"\\byarn\\s+(?:global\\s+(?:add|remove|upgrade)|add\\b[^&|;]*\\s-g(?![\\w-]))",
|
|
47
|
+
"pip3?\\s+install\\s+[^&|;]*(--global|--user)",
|
|
48
|
+
"apt(?:-get)?\\s+install",
|
|
49
|
+
"docker\\s+run\\b[^&|;]*--privileged",
|
|
50
|
+
// 四、对外发送与发布:--dry-run 不发送,整段内出现即豁免
|
|
51
|
+
"\\b(?:npm|pnpm|yarn)\\s+publish\\b(?![^&|;]*--dry-run)",
|
|
52
|
+
"docker\\s+push",
|
|
53
|
+
"curl\\b[^&|;]*(-X\\s*POST|--request\\s+POST)",
|
|
54
|
+
"wget\\b[^&|;]*(--post-data|--post-file)",
|
|
55
|
+
"(?:Invoke-WebRequest|Invoke-RestMethod)\\b[^&|;]*-Method\\s+POST",
|
|
56
|
+
// 五、系统/容器级破坏
|
|
57
|
+
"docker\\s+(?:system\\s+prune|volume\\s+rm)",
|
|
58
|
+
"mkfs",
|
|
59
|
+
"format\\s+[a-z]:",
|
|
60
|
+
"diskpart",
|
|
61
|
+
"\\bdd\\s+[^&|;]*of=/dev/",
|
|
62
|
+
"\\bshutdown\\b",
|
|
63
|
+
"\\bfind\\b[^&|;]*-delete\\b",
|
|
64
|
+
"\\brimraf\\b",
|
|
65
|
+
].join("|"), "i");
|
|
66
|
+
export const SQL_NOWHERE_RE = /\bdelete\s+from\s+[\w`."]+|\bupdate\s+[\w`."]+\s+set\b/i; // 2.4.0:无 where 的 DELETE FROM / UPDATE...SET
|
|
67
|
+
// 2.5.2:WHERE 豁免按"单条语句"判定——旧写法只要整行任意位置出现 where,就把同行的无 where 删除一并放过。
|
|
68
|
+
export function sqlNowhere(cmd) {
|
|
69
|
+
return String(cmd || "")
|
|
70
|
+
.split(";")
|
|
71
|
+
.some((s) => SQL_NOWHERE_RE.test(s) && !/\bwhere\b/i.test(s));
|
|
72
|
+
}
|
|
73
|
+
// 2.5.2:curl 的 -d/--data 必须区分大小写(-D 是 dump 响应头,属只读 GET),故单独用无 /i 的正则
|
|
74
|
+
export const CURL_DATA_RE = /curl\b[^&|;]*(\s-d\b|\s--data(?:-raw|-binary|-urlencode)?\b)/;
|
|
75
|
+
export const SCRIPT_FILE_RE = /\.(sh|ps1|bat|cmd|py|pl|rb|mjs|cjs|js)$/i; // 2.4.0:脚本包装检测范围
|
|
76
|
+
|
|
77
|
+
// 2.5.2 git 高危子命令:逐段取 git 调用 → 剥掉 git 全局选项 → 看子命令。
|
|
78
|
+
// 旧写法用前缀组硬凑 "-C/-c + 取值",遇到 --git-dir=/x、--no-pager、-C= 之类穿插即绕过;且只看首段会漏 `a && git push`。
|
|
79
|
+
// --dry-run(clean 为 -n)在该命令段内任意位置出现即豁免——dry-run 不产生任何不可逆后果。
|
|
80
|
+
export const GIT_OPT_WITH_VALUE = /(?:^|\s)(?:-[Cc]|--git-dir|--work-tree|--namespace|--exec-path|--config-env)(?:=\S+|\s+\S+)?/g;
|
|
81
|
+
export const GIT_OPT_VALUELESS = /(?:^|\s)(?:--no-pager|--paginate|--bare|--literal-pathspecs|--no-replace-objects|--no-optional-locks)\b/g;
|
|
82
|
+
export function gitHighRisk(cmd) {
|
|
83
|
+
for (const seg of String(cmd || "").split(/[;&|]+/)) {
|
|
84
|
+
const m = seg.match(/\bgit\b([\s\S]*)$/i);
|
|
85
|
+
if (!m) continue;
|
|
86
|
+
const rest = m[1].replace(GIT_OPT_WITH_VALUE, " ").replace(GIT_OPT_VALUELESS, " ").trim();
|
|
87
|
+
if (/^push\b/i.test(rest)) {
|
|
88
|
+
if (!/--dry-run\b/i.test(rest)) return true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (/^reset\b/i.test(rest)) {
|
|
92
|
+
if (/--hard\b/i.test(rest)) return true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (/^clean\b/i.test(rest)) {
|
|
96
|
+
const force = /(?:^|\s)-[a-zA-Z]*f[a-zA-Z]*\b|--force\b/i.test(rest);
|
|
97
|
+
const dry = /(?:^|\s)-[a-zA-Z]*n[a-zA-Z]*\b|--dry-run\b/i.test(rest);
|
|
98
|
+
if (force && !dry) return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2.5.2:审批/否决的比对键改用内容哈希。旧写法把命令截断到 300 字符再与原文比对,
|
|
105
|
+
// 于是超过 300 字符的命令永远等不到匹配——人类按 y 白按(highRiskOk 被消耗但仍不匹配)、
|
|
106
|
+
// 按 n 也存不进 rejectedCmds("彻底阻断"静默失效)。显示文本仍截断,比对用全量哈希。
|
|
107
|
+
export function cmdKey(s) {
|
|
108
|
+
return createHash("sha256").update(String(s)).digest("hex").slice(0, 24);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isDangerousCmd(cmd) {
|
|
112
|
+
const c = String(cmd || "");
|
|
113
|
+
if (gitHighRisk(c)) return true;
|
|
114
|
+
return DANGEROUS_PATTERNS.test(c) || sqlNowhere(c) || CURL_DATA_RE.test(c);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ───────── 3.0.6 P0 · 绝对红线的上下文豁免(HANDOFF §八 立项)─────────
|
|
118
|
+
// 问题:绝对红线在**文本层**匹配,无法区分「要执行的命令」与「被引用的命令字符串」。
|
|
119
|
+
// 后者是把危险命令当数据——写回归测试、构造安全评测集、引用文献、教学示例——
|
|
120
|
+
// 却会被直接拒绝,护栏因此在伤害它自己的开发。
|
|
121
|
+
// 纪律:豁免只把裁决**降级到第 2 层语义预判**,绝不直接放行;豁免判据必须可审计
|
|
122
|
+
// (AUDIT.log 记 redline-exempt + 判据名 + 命中片段)。
|
|
123
|
+
// 安全前提(§八 未列、但缺了就是绕过通道):内容落在引号内**不足以**豁免——
|
|
124
|
+
// `bash -c "rm -rf /"`、`node -e "...execSync('rm -rf /')"` 的危险内容同样在引号里,
|
|
125
|
+
// 那是真执行。故凡检出执行外壳(shell -c / eval / iex / 解释器 -e|-c 等)一律不豁免。
|
|
126
|
+
// (解释器一并排除,口径与 R5-3 解释器黑名单一致。)
|
|
127
|
+
|
|
128
|
+
/** 执行外壳:会把引号内字符串真正执行掉的写法。命中即不得豁免。 */
|
|
129
|
+
export const SHELL_EXEC_WRAPPER_RE =
|
|
130
|
+
/(?:\b(?:ba|z|k|da|a)?sh\s+-[a-z]*c\b|\b(?:pwsh|powershell)(?:\.exe)?\s+[^|;]*-(?:[a-z]*c|Command)\b|\bcmd(?:\.exe)?\s+\/[ck]\b|\beval\b|\bexec\b|\biex\b|Invoke-Expression|\b(?:node|deno|bun)\s+(?:-e|--eval)\b|\bpython[0-9.]*\s+-c\b|\b(?:perl|ruby|php)\s+-[er]\b)/i;
|
|
131
|
+
|
|
132
|
+
/** 数据用途标记:命中片段之前出现,表明内容是"被引用的数据"。 */
|
|
133
|
+
export const DATA_MARKER_RES = [
|
|
134
|
+
/示例[::]/,
|
|
135
|
+
/例如[::]/,
|
|
136
|
+
/测试数据/,
|
|
137
|
+
/\btest\s+data\b/i,
|
|
138
|
+
/\bprompt\s*[:=]/i,
|
|
139
|
+
/【假设】/,
|
|
140
|
+
/```/,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** 只读输出命令:首 token 属此类时,命令本身不产生变更。 */
|
|
144
|
+
export const READONLY_HEAD_RE =
|
|
145
|
+
/^(?:echo|printf|Write-Output|Write-Host|Write-Verbose|Out-String|Out-Host|Out-File\s+[^|;]*-WhatIf|Get-Content|type|cat|Select-String|findstr)\b/i;
|
|
146
|
+
|
|
147
|
+
/** 列出字符串中被引号包裹的内容区间 [start, end)。处理反斜杠转义。 */
|
|
148
|
+
export function quotedSpans(s) {
|
|
149
|
+
const spans = [];
|
|
150
|
+
const str = String(s || "");
|
|
151
|
+
let i = 0;
|
|
152
|
+
while (i < str.length) {
|
|
153
|
+
const ch = str[i];
|
|
154
|
+
if (ch === "\\") {
|
|
155
|
+
i += 2;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (ch === '"' || ch === "'") {
|
|
159
|
+
const quote = ch;
|
|
160
|
+
const start = i + 1;
|
|
161
|
+
i += 1;
|
|
162
|
+
while (i < str.length) {
|
|
163
|
+
if (str[i] === "\\") {
|
|
164
|
+
i += 2;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (str[i] === quote) break;
|
|
168
|
+
i += 1;
|
|
169
|
+
}
|
|
170
|
+
spans.push([start, i]);
|
|
171
|
+
i += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
i += 1;
|
|
175
|
+
}
|
|
176
|
+
return spans;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** span 尾部要裁掉的字符:尾随空白与收尾引号(分隔符)。不含 `/`——`rm -rf /` 的 `/` 是目标路径本体。 */
|
|
180
|
+
const SPAN_TAIL_TRIM_RE = /[\s"']/;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 命中片段的本体区间 [start, end)。
|
|
184
|
+
*
|
|
185
|
+
* 3.0.7(HANDOFF §十一 缺陷 6):红线正则尾部常带 `[\/\s"']*` 这类收尾类,贪婪匹配会
|
|
186
|
+
* 连同尾随空白与收尾引号一起吃掉,使 span 越过引号内容区间 → 判据 1(quoted-literal)
|
|
187
|
+
* 判空,改由判据 3(readonly-head)命中。豁免结果虽然相同,但审计记录的判据名不稳定。
|
|
188
|
+
* 裁掉尾随的空白与引号后,span 稳定落在危险片段本体上。
|
|
189
|
+
* 全裁空时回退原文区间,避免产生零宽 span 引出误判。
|
|
190
|
+
*/
|
|
191
|
+
function redlineSpan(c, m) {
|
|
192
|
+
const start = m.index;
|
|
193
|
+
let end = start + m[0].length;
|
|
194
|
+
while (end > start && SPAN_TAIL_TRIM_RE.test(c[end - 1])) end -= 1;
|
|
195
|
+
return end > start ? [start, end] : [start, start + m[0].length];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 红线上下文豁免判定。
|
|
200
|
+
* @param {string} cmd 待检命令原文
|
|
201
|
+
* @param {{name: string, re: RegExp}} hit 命中的红线条目
|
|
202
|
+
* @returns {{basis: string, detail: string, span: number[]}|null} 豁免依据;不豁免返回 null
|
|
203
|
+
*/
|
|
204
|
+
export function redlineExempt(cmd, hit) {
|
|
205
|
+
const c = String(cmd || "");
|
|
206
|
+
if (!c || !hit || !(hit.re instanceof RegExp)) return null;
|
|
207
|
+
const m = hit.re.exec(c);
|
|
208
|
+
if (!m) return null;
|
|
209
|
+
const span = redlineSpan(c, m);
|
|
210
|
+
|
|
211
|
+
// 安全前提:执行外壳一律不豁免(引号内也能被真正执行)
|
|
212
|
+
if (SHELL_EXEC_WRAPPER_RE.test(c)) return null;
|
|
213
|
+
|
|
214
|
+
// 判据 1:命中片段完整落在某个引号字面量内 → 内容是字符串数据
|
|
215
|
+
const inside = quotedSpans(c).find(([s, e]) => s <= span[0] && span[1] <= e);
|
|
216
|
+
if (inside) {
|
|
217
|
+
return {
|
|
218
|
+
basis: "quoted-literal",
|
|
219
|
+
detail: `命中片段位于引号字面量内 [${inside[0]},${inside[1]})`,
|
|
220
|
+
span,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 判据 2:命中片段之前有显式数据标记,且命令本身非变更类
|
|
225
|
+
const before = c.slice(0, m.index);
|
|
226
|
+
const marker = DATA_MARKER_RES.find((re) => re.test(before));
|
|
227
|
+
if (marker && !isMutatingBashCmd(c)) {
|
|
228
|
+
return { basis: "data-marker", detail: `片段前有数据标记 ${String(marker)},且命令非变更类`, span };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 判据 3:首 token 是只读输出命令
|
|
232
|
+
const head = c.trim().replace(CMD_PREFIX_RE, "").trim();
|
|
233
|
+
if (READONLY_HEAD_RE.test(head)) {
|
|
234
|
+
return { basis: "readonly-head", detail: `只读输出命令:${head.split(/\s+/)[0]}`, span };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// FocusGuard 母版层 · risk 启发式(v3.0.5;《资料与代码分层总规范》二·1)
|
|
2
|
+
// 变更/侦查判定、稳定序列化与调用哈希、打回与记罚(独立于 harness 的判定接口)。
|
|
3
|
+
import {
|
|
4
|
+
readFileSync, writeFileSync, rmSync, statSync, appendFileSync,
|
|
5
|
+
mkdirSync, existsSync, renameSync, realpathSync, readdirSync, copyFileSync,
|
|
6
|
+
} from "node:fs";
|
|
7
|
+
import { join, dirname, sep, basename, relative } from "node:path";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { INTERP_EVAL_RE } from "./constants.mjs";
|
|
12
|
+
import { isMutatingBashCmd, FILE_REDIRECT_RE } from "./redlines.mjs";
|
|
13
|
+
import { audit } from "./audit.mjs";
|
|
14
|
+
|
|
15
|
+
export function isMutating(tool, ti, handoff) {
|
|
16
|
+
if (tool === "Write" || tool === "Edit") return !handoff;
|
|
17
|
+
if (tool === "Bash") {
|
|
18
|
+
const cmd = String(ti.command || "");
|
|
19
|
+
return isMutatingBashCmd(cmd) || FILE_REDIRECT_RE.test(cmd);
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isInvestigation(tool, ti) {
|
|
25
|
+
if (["Read", "Grep", "Glob", "WebSearch", "WebFetch"].includes(tool)) return true;
|
|
26
|
+
if (/mcp__.*(web|search)/i.test(tool)) return true;
|
|
27
|
+
if (tool === "Bash") {
|
|
28
|
+
const cmd = String(ti.command || "");
|
|
29
|
+
// 3.0.0 解释器黑名单(R5-3):`python -c`/`node -e` 等等价任意代码执行,不得因"非变异"混入只读侦查
|
|
30
|
+
// (否则熔断期白名单放行解释器=熔断失效,侦查池也被 eval 类命令挤占)
|
|
31
|
+
return !isMutatingBashCmd(cmd) && !FILE_REDIRECT_RE.test(cmd) && !INTERP_EVAL_RE.test(cmd);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function stable(v) {
|
|
37
|
+
if (Array.isArray(v)) return "[" + v.map(stable).join(",") + "]";
|
|
38
|
+
if (v && typeof v === "object")
|
|
39
|
+
return "{" + Object.keys(v).sort().map((k) => k + ":" + stable(v[k])).join(",") + "}";
|
|
40
|
+
return JSON.stringify(v) ?? String(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function callHash(input) {
|
|
44
|
+
const sig = (input.tool_name || "?") + "|" + stable(input.tool_input ?? {});
|
|
45
|
+
return sig.slice(0, 500);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function collectStrings(v, out, budget) {
|
|
49
|
+
if (typeof v === "string") {
|
|
50
|
+
if (out.total < budget) {
|
|
51
|
+
out.parts.push(v);
|
|
52
|
+
out.total += v.length;
|
|
53
|
+
}
|
|
54
|
+
} else if (Array.isArray(v)) {
|
|
55
|
+
for (const x of v) collectStrings(x, out, budget);
|
|
56
|
+
} else if (v && typeof v === "object") {
|
|
57
|
+
for (const x of Object.values(v)) collectStrings(x, out, budget);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function block(reason) {
|
|
62
|
+
process.stdout.write(JSON.stringify({ decision: "block", reason }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function penalize(state, sid, trigger, evidence) {
|
|
66
|
+
state.violations = (state.violations || 0) + 1;
|
|
67
|
+
const level = Math.min(state.violations, 6);
|
|
68
|
+
if (level >= 2) state.forcedInvestigate = true;
|
|
69
|
+
if (level >= 3) state.fused = true;
|
|
70
|
+
if (level >= 5) state.probation = true;
|
|
71
|
+
audit(sid, trigger, { level, evidence });
|
|
72
|
+
return level;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ===== 3.0.1(七十五条(四))批量审批:多条待批合并出示、一次批示;批示词容错后缀 =====
|