focus-guard 0.0.0-stage → 3.0.7

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/README.zh-CN.md +98 -0
  4. package/docs/LEGISLATION-LAW.md +102 -0
  5. package/docs/MASTER-PLAN-2.0.0.md +133 -0
  6. package/docs/MASTER-PLAN-3.0.0.md +76 -0
  7. package/docs/RULES.md +416 -0
  8. package/docs/deepseek-pricing-audit.md +63 -0
  9. package/hooks/guard.mjs +1142 -0
  10. package/hooks/hooks.json +76 -0
  11. package/package.json +61 -4
  12. package/skills/focus-thinking/SKILL.md +299 -0
  13. package/src/adapters/dsh/eligibility-gate.mjs +302 -0
  14. package/src/adapters/dsh/fg-appeal-tool.mjs +47 -0
  15. package/src/adapters/dsh/fg-apply-tool.mjs +68 -0
  16. package/src/adapters/dsh/protocol.mjs +42 -0
  17. package/src/core/approval.mjs +64 -0
  18. package/src/core/audit.mjs +71 -0
  19. package/src/core/backup.mjs +55 -0
  20. package/src/core/checkEligibility.mjs +271 -0
  21. package/src/core/constants.mjs +80 -0
  22. package/src/core/decisionEngine.mjs +45 -0
  23. package/src/core/env.mjs +113 -0
  24. package/src/core/grants.mjs +63 -0
  25. package/src/core/profileLoader.mjs +122 -0
  26. package/src/core/redlines.mjs +238 -0
  27. package/src/core/risk.mjs +75 -0
  28. package/src/core/session.mjs +9 -0
  29. package/src/core/state.mjs +265 -0
  30. package/src/dsh/audit.mjs +192 -0
  31. package/src/dsh/compactionWatch.mjs +407 -0
  32. package/src/dsh/cordis.patch.yml +10 -0
  33. package/src/dsh/heartbeat.mjs +128 -0
  34. package/src/dsh/index.js +146 -0
  35. package/src/dsh/pipeline.mjs +908 -0
  36. package/src/dsh/postProgress.mjs +362 -0
  37. package/src/dsh/preGuard.mjs +288 -0
  38. package/src/dsh/seams.mjs +454 -0
  39. package/src/dsh/selftest.mjs +463 -0
  40. package/src/dsh/sentinel.py +160 -0
  41. package/src/dsh/status.mjs +73 -0
  42. package/src/dsh/stopGuard.mjs +259 -0
  43. package/src/dsh/toolName.mjs +41 -0
  44. package/src/dsh/tools.mjs +115 -0
  45. package/src/dsh/workspace.mjs +145 -0
  46. package/src/peak-cost.mjs +115 -0
  47. package/src/profiles/deepseek-flash.mjs +24 -0
  48. package/src/profiles/deepseek-pro.mjs +25 -0
  49. package/src/profiles/default.mjs +26 -0
  50. package/src/profiles/glm.mjs +26 -0
  51. package/src/profiles/gpt-astra.mjs +30 -0
  52. package/tools/audit-chain.mjs +105 -0
  53. package/tools/env-fingerprint.mjs +88 -0
  54. package/tools/library-build.mjs +222 -0
  55. package/tools/sentinel.mjs +121 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * focus-guard 适配层 · 资格审核闸(3.0.5 第二步)
3
+ *
4
+ * 双入口闭环:
5
+ * 入口一 fg_apply(applyEligibility + FG_APPLY_TOOL_SPEC):AI 提交目的/影响范围 → 审核 → 通过则按 session 授权
6
+ * 入口二 gateToolCall(pre-execute 调用):高危工具无授权 → deny,理由指向 fg_apply
7
+ *
8
+ * 分层纪律:本文件属适配层,向母版注入 redlines / audit / model;母版不反向依赖本层。
9
+ * grants 是 FG 自己的状态(按 session 分表,内存版;第五步持久化到 FG 状态文件),不进 guard.mjs 的 state。
10
+ * 审计默认写 .focus-guard/AUDIT.log;FG_AUDIT_FILE 可重定向——自检**只写 tmpdir()**,绝不碰真实工作区。
11
+ */
12
+ import { appendFileSync } from 'node:fs'
13
+ import { checkEligibility, HIGH_RISK_TOOLS, SYSTEM_PATH_RE, profileScope, IRREVERSIBLE_IDS } from '../../core/checkEligibility.mjs'
14
+ import { redlineExempt } from '../../core/redlines.mjs'
15
+ import { loadProfile } from '../../core/profileLoader.mjs'
16
+ import { createGrantTable } from '../../core/grants.mjs'
17
+ import { AUDIT_FILE, appendAudit } from '../../dsh/audit.mjs'
18
+
19
+ /** 对系统路径的写入类工具:与命令型高危同属"必须先申请"的门槛清单(显式成文)。 */
20
+ export const GATED_WRITE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
21
+
22
+ /**
23
+ * 判定本次调用是否属"必须先申请"的类别。
24
+ * @returns {string|null} 命中的门槛 id(rm-rf / format-volume / … / write-system-path),不属门槛则 null
25
+ */
26
+ export function gatedReasonOf(tool, command = '') {
27
+ const cmd = String(command ?? '')
28
+ const hit = HIGH_RISK_TOOLS.find((r) => r.re.test(cmd))
29
+ if (hit) {
30
+ // 3.0.7(HANDOFF §十一 缺陷 3):与红线层 / 母版 L2 同口径。
31
+ // HIGH_RISK_TOOLS 只做文本匹配,无法区分「要执行的命令」与「被引用的命令字符串」,
32
+ // 于是引号内的危险数据虽已被红线层与母版 L2 豁免,却仍进门槛清单被闸直接 deny——
33
+ // 三方口径不一致。此处对命令型条目先问一次上下文豁免:豁免成立则不进门槛清单,
34
+ // 交回原有判定链(pipeline 文本层 → 硬校验 → 本闸 → 语义预判),由下游按正常语义裁决。
35
+ // 豁免不成立(裸危险命令、执行外壳包装)时行为不变,仍按门槛拦。
36
+ if (redlineExempt(cmd, hit)) return null
37
+ return hit.id
38
+ }
39
+ const name = String(tool ?? '').toLowerCase()
40
+ // 注意:本支由 SYSTEM_PATH_RE 判定的**路径检查**,不是命令红线,不套命令豁免
41
+ //(豁免函数只对 HIGH_RISK_TOOLS 中的命令型条目有意义)。
42
+ if (GATED_WRITE_TOOLS.some((t) => t.toLowerCase() === name) && SYSTEM_PATH_RE.test(cmd)) return 'write-system-path'
43
+ return null
44
+ }
45
+
46
+ // —— FG 自己的授权状态:按 session 分表(内存;第五步持久化)——
47
+ const TABLES = new Map()
48
+
49
+ /** 该会话的授权表(不存在则建)。 */
50
+ export function grantsFor(session) {
51
+ const key = String(session || 'dsh-native')
52
+ let table = TABLES.get(key)
53
+ if (!table) {
54
+ table = createGrantTable()
55
+ TABLES.set(key, table)
56
+ }
57
+ return table
58
+ }
59
+
60
+ /** 回收整张表(回合/任务结束;第五步由 ttl 计时自动调用)。 */
61
+ export function resetGrants(session) {
62
+ return TABLES.delete(String(session || 'dsh-native'))
63
+ }
64
+
65
+ /**
66
+ * 资格审核审计(规格 §七 字段口径)。统一走 `appendAudit`——由它负责体积轮转,
67
+ * 并与 auditDeny / auditRedlineExempt 共用同一写入口径(2026-10-09 污染事故的教训)。写失败只 warn。
68
+ */
69
+ export function auditEligibility(row) {
70
+ return appendAudit({ ts: new Date().toISOString(), ...row })
71
+ }
72
+
73
+ /**
74
+ * 入口一的核心:申请 → 审核 → 授权。
75
+ *
76
+ * 关键语义:fg_apply 本身是**申请通道**,所以审核期间先把该工具视为"已有授权",让母版第 3 层
77
+ * 聚焦于"可否授予"(状态/红线/前置/语义),而不是被"无授权即需审批"挡在自己的入口上。
78
+ * 审核未通过时立即收回这一临时授权——被拒就绝不能放行。
79
+ *
80
+ * @returns {Promise<{decision:"allow"|"deny"|"needApproval", layer:string, reason:string, modelSignal:object|null, trace:Array}>}
81
+ */
82
+ export async function applyEligibility({
83
+ session,
84
+ tool,
85
+ command = '',
86
+ purpose,
87
+ scope,
88
+ ttl = 'turn',
89
+ target,
90
+ state = {},
91
+ redlines = [],
92
+ model = null,
93
+ profile = null,
94
+ modelId,
95
+ }) {
96
+ const grants = grantsFor(session)
97
+ const preGranted = grants.has(tool)
98
+ if (!preGranted) grants.grant(tool, { ttl, reason: 'fg_apply 审核中(临时)' })
99
+ // 3.0.6 补口(HANDOFF §十 缺陷 1):画像必须透传到母版,否则真实 fg_apply 路径 profile=null,
100
+ // 画像对所有层失效——此前画像差异只在 decide() 那条路可达。
101
+ const resolved = profile ?? (modelId ? loadProfile(modelId) : null)
102
+ const result = await checkEligibility({
103
+ tool,
104
+ command,
105
+ purpose,
106
+ scope,
107
+ ttl,
108
+ target,
109
+ state,
110
+ redlines,
111
+ model,
112
+ profile: resolved,
113
+ grants,
114
+ audit: (row) => auditEligibility({ session, ...row }),
115
+ })
116
+ if (result.decision !== 'allow' && !preGranted) grants.revoke(tool)
117
+ return result
118
+ }
119
+
120
+ /**
121
+ * 入口二:pre-execute 的 grants 校验。
122
+ * @returns {{kind:"pass", granted?: boolean}} 不属门槛清单、或已有授权 → 交回原有判定链
123
+ * {{kind:"deny", reason: string}} 属门槛且无授权 → 拦截
124
+ */
125
+ export function gateToolCall({ session, tool, command = '', state, profile = null } = {}) {
126
+ const reason = gatedReasonOf(tool, command)
127
+ if (!reason) return { kind: 'pass' }
128
+ // 3.0.6(HANDOFF §十 缺陷 5):与母版 L3 同源——审批层**不可被画像关闭**(只受 scope 约束),
129
+ // 否则会出现"decide 放行而闸拦截"或"闸放行而母版拦截"的路径分叉。
130
+ const scope = profileScope(profile)
131
+ if (scope === 'irreversible' && !IRREVERSIBLE_IDS.includes(reason)) {
132
+ return { kind: 'pass', outOfScope: true }
133
+ }
134
+ const grants = grantsFor(session)
135
+ if (grants.has(tool)) return { kind: 'pass', granted: true }
136
+ const detail = `该工具需先调 fg_apply 申请授权(命中 ${reason})`
137
+ auditEligibility({
138
+ session,
139
+ action: 'deny',
140
+ tool,
141
+ command: String(command ?? '').slice(0, 120),
142
+ layer: 'gate',
143
+ decision: 'deny',
144
+ modelSignal: null,
145
+ evidence: detail,
146
+ })
147
+ return { kind: 'deny', reason: detail }
148
+ }
149
+
150
+ /**
151
+ * fg_apply 的工具注册规格。接线时套用装机 dsh-tools 的 defineTool 契约(name/description/parameters +
152
+ * 执行体),执行体只需 await applyEligibility(args) 并按 decision 渲染文案,无额外逻辑。
153
+ */
154
+ export const FG_APPLY_TOOL_SPEC = {
155
+ name: 'fg_apply',
156
+ description:
157
+ '向 FocusGuard 申请高危工具的临时执行资格。执行高危命令(rm -rf / 格式化 / git push -f / 发布 / 全局安装 / 系统路径写入)之前必须先申请;purpose 与 scope 必填。通过后本会话内按 ttl 放行,被拒则不得执行。',
158
+ parameters: {
159
+ tool: { type: 'string', required: true, description: '目标工具名,如 Bash / Write / Edit' },
160
+ command: { type: 'string', required: true, description: '命令或操作描述' },
161
+ purpose: { type: 'string', required: true, description: '真实目的(必填,不得空白)' },
162
+ scope: { type: 'string', required: true, description: '影响范围(必填,不得空白)' },
163
+ ttl: { type: 'string', required: false, enum: ['turn', 'task', 'persist'], description: '授权有效期,默认 turn' },
164
+ target: { type: 'string', required: false, description: '目标路径(改动类可选,供第 4 层取证比对)' },
165
+ },
166
+ }
167
+
168
+ // ═══════════ 3.0.7 · 误伤申辩(司法救济通道)═══════════
169
+ // 缺口背景(外部审计指出、并经逐字复核成立):原体系只有"立法救济"(事后改规则),
170
+ // 没有"司法救济"(个案当场申辩)。HANDOFF §八 的红线误伤即实证——被拦后AI无程序可走,
171
+ // 只能绕过([char]47 拼接)或等修法(3.0.6 上下文豁免),成本差两个数量级。
172
+ // 本机制复用既有审批骨架:申辩 → {kind:"ask"} → 人类一次性裁决 → 留痕。
173
+ // 注意:第八十三条(三) 原写"拦截申诉…仍走第十二章",而第十二章是【反规避与纪律审查】(处罚章),
174
+ // 属条文错配——本机制即为该错配补上的实体程序。
175
+
176
+ /** 红线豁免凭据的授权键前缀:申辩获批后按 `redline:<名称>` 记账,供 pipeline 红线层查询。 */
177
+ export const REDLINE_GRANT_PREFIX = 'redline:'
178
+
179
+ /** 取会话标识(与 audit.mjs 的 auditDeny 口径一致)。 */
180
+ function sessionOf(exec) {
181
+ return (exec && (exec.sessionId || (exec.agent && (exec.agent.sessionId || exec.agent.id)))) || 'dsh-native'
182
+ }
183
+
184
+ /**
185
+ * 查该红线是否已有"申辩获批"凭据(3.0.7)。
186
+ * pipeline 红线层在上下文豁免判据之外追加查这一道。
187
+ * @returns {Promise<boolean>}
188
+ */
189
+ export async function hasRedlineGrant(exec, redlineName) {
190
+ if (!redlineName) return false
191
+ return grantsFor(sessionOf(exec)).has(`${REDLINE_GRANT_PREFIX}${redlineName}`)
192
+ }
193
+
194
+ /**
195
+ * 申辩入口(3.0.7):把 fg_appeal 的这次调用转成 `{kind:"ask"}`,交 DSH approval seam 由人类裁决。
196
+ * 由 pipeline 在**所有闸之前**调用——申辩参数携带被拦命令原文,走后续闸会被同一规则再拦一次。
197
+ * @returns {Promise<{kind:"ask", reason:string}|{kind:"deny", reason:string}|null>}
198
+ */
199
+ export async function appealAsk(exec, warn) {
200
+ const args = (exec && exec.arguments) || {}
201
+ const target = String(args.tool ?? '').trim()
202
+ const blocked = String(args.command ?? '')
203
+ const counterExample = String(args.counterExample ?? '').trim()
204
+ const reason = String(args.reason ?? '').trim()
205
+ const session = sessionOf(exec)
206
+
207
+ if (!target || !reason) {
208
+ const msg = '申辩表单不完整:tool 与 reason 必填;建议附 counterExample 反例锚点(文件:行号 或原文引用)'
209
+ auditEligibility({
210
+ session,
211
+ action: 'appeal-denied',
212
+ tool: target || '(missing)',
213
+ command: blocked.slice(0, 120),
214
+ layer: 'appeal',
215
+ decision: 'deny',
216
+ modelSignal: null,
217
+ evidence: msg,
218
+ })
219
+ if (typeof warn === 'function') warn('申辩表单不完整:', msg)
220
+ return { kind: 'deny', reason: `focus-guard-native: ${msg}` }
221
+ }
222
+
223
+ auditEligibility({
224
+ session,
225
+ action: 'appeal-filed',
226
+ tool: target,
227
+ command: blocked.slice(0, 120),
228
+ layer: 'appeal',
229
+ decision: 'needApproval',
230
+ modelSignal: null,
231
+ evidence: `反例锚点:${counterExample.slice(0, 200) || '(未提供)'} | 理由:${reason.slice(0, 200)}`,
232
+ })
233
+ if (typeof warn === 'function') warn('申辩已提交人类裁决:', `${target} ${blocked.slice(0, 80)}`)
234
+
235
+ return {
236
+ kind: 'ask',
237
+ reason:
238
+ `focus-guard-native:【误判申辩】\n` +
239
+ `工具:${target}\n` +
240
+ `被拦命令:${blocked.slice(0, 200) || '(未提供)'}\n` +
241
+ `反例锚点:${counterExample.slice(0, 300) || '(未提供)'}\n` +
242
+ `理由:${reason.slice(0, 300)}\n\n` +
243
+ `批准 → 为该工具开通一次授权(ttl=turn),并豁免本次命中的红线;拒绝 → 维持拦截。`,
244
+ }
245
+ }
246
+
247
+ /**
248
+ * 申辩获批后的授予(3.0.7):由 fg_appeal 的执行体调用(人类批准后才轮到 execute 运行)。
249
+ * 同时记"工具授权"与"红线凭据",使原被拦命令的下次调用能过红线层与资格闸。
250
+ */
251
+ export async function grantFromAppeal({ session, tool, command = '' } = {}) {
252
+ const grants = grantsFor(session)
253
+ const entry = grants.grant(String(tool), {
254
+ ttl: 'turn',
255
+ reason: `申辩获批:${String(command).slice(0, 80)}`,
256
+ })
257
+ let redline = null
258
+ try {
259
+ // 路径相对本文件(src/adapters/dsh/):上溯两级到 src/,再进 dsh/
260
+ const { ABSOLUTE_REDLINES } = await import('../../dsh/pipeline.mjs')
261
+ const hit = (ABSOLUTE_REDLINES || []).find((r) => r && r.re && r.re.test(String(command)))
262
+ if (hit) {
263
+ grants.grant(`${REDLINE_GRANT_PREFIX}${hit.name}`, { ttl: 'turn', reason: '申辩获批红线豁免' })
264
+ redline = hit.name
265
+ }
266
+ } catch (error) {
267
+ // 不静默:红线凭据拿不到时工具授权仍生效,但必须留痕(对齐 2.5.1「假留痕防线」)
268
+ console.warn(
269
+ '[focus-guard-native] 申辩红线凭据授予失败(工具授权仍生效):',
270
+ (error && error.message) || error,
271
+ )
272
+ redline = null
273
+ }
274
+ auditEligibility({
275
+ session,
276
+ action: 'appeal-granted',
277
+ tool,
278
+ command: String(command).slice(0, 120),
279
+ layer: 'appeal',
280
+ decision: 'allow',
281
+ modelSignal: null,
282
+ evidence: `获批授权 ttl=${entry.ttl}${redline ? `;红线豁免:${redline}` : ''}`,
283
+ })
284
+ return { entry, redline }
285
+ }
286
+
287
+ /** fg_appeal 的工具注册规格(与 fg_apply 同构;执行体只在人类批准后运行)。 */
288
+ export const FG_APPEAL_TOOL_SPEC = {
289
+ name: 'fg_appeal',
290
+ description:
291
+ '对被 FocusGuard 拦截的命令提出【误判申辩】。仅当认为拦截属误伤时使用(例如被引用的危险命令字符串被当成了要执行的命令)。须给出 reason,并尽量附 counterExample 反例锚点(文件:行号 或原文引用)。提交后由人类一次性裁决:批准则开通该工具一次授权并豁免对应红线,拒绝则维持拦截。',
292
+ parameters: {
293
+ tool: { type: 'string', required: true, description: '被拦的工具名,如 Bash / Write' },
294
+ command: { type: 'string', required: true, description: '被拦的命令或操作原文' },
295
+ reason: { type: 'string', required: true, description: '申辩理由(必填,不得空白)' },
296
+ counterExample: {
297
+ type: 'string',
298
+ required: false,
299
+ description: '反例锚点:证明该片段只是数据的证据,如 文件:行号 或原文引用',
300
+ },
301
+ },
302
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * focus-guard 适配层 · fg_appeal 工具注册(3.0.7 · 误伤申辩)
3
+ *
4
+ * 契约与 fg-apply-tool.mjs 相同:defineTool({ name, description, parameters, execute })。
5
+ * 执行体只在**人类批准申辩后**才运行——ask 由 pipeline 在 pre-execute 最先发起
6
+ * (原因是申辩参数携带被拦命令原文,若走后续闸会被同一规则再拦一次)。
7
+ * 所以 execute 的职责就是"授予":开通该工具一次授权 + 豁免本次命中的红线。
8
+ */
9
+ import { defineTool } from '@deepseek-ai/dsh-tools'
10
+ import { FG_APPEAL_TOOL_SPEC, grantFromAppeal } from './eligibility-gate.mjs'
11
+
12
+ export const name = 'focus-guard-fg-appeal'
13
+ /** 依赖注入:注册到 ctx.tools 即可。 */
14
+ export const inject = ['tools']
15
+
16
+ /** 从 exec 取会话标识,口径与 audit.mjs 的 auditDeny 一致。 */
17
+ function sessionOf(exec) {
18
+ return (exec && (exec.sessionId || (exec.agent && (exec.agent.sessionId || exec.agent.id)))) || 'dsh-native'
19
+ }
20
+
21
+ /** 生成 fg_appeal 的工具定义。 */
22
+ export function buildFgAppealTool() {
23
+ return defineTool({
24
+ name: FG_APPEAL_TOOL_SPEC.name,
25
+ description: FG_APPEAL_TOOL_SPEC.description,
26
+ parameters: FG_APPEAL_TOOL_SPEC.parameters,
27
+ async execute(args, exec) {
28
+ const { entry, redline } = await grantFromAppeal({
29
+ session: sessionOf(exec),
30
+ tool: args.tool,
31
+ command: args.command ?? '',
32
+ })
33
+ return {
34
+ text:
35
+ `[申辩获批] ${args.tool} 已开通一次授权(ttl=${entry.ttl})` +
36
+ (redline ? `,并豁免红线「${redline}」。` : '。') +
37
+ `\n现在可以执行原被拦命令;如需更多次数请重新申请。`,
38
+ }
39
+ },
40
+ })
41
+ }
42
+
43
+ /** Cordis 插件入口:把 fg_appeal 注册进 ctx.tools。 */
44
+ export function apply(ctx, config) {
45
+ void config
46
+ ctx.tools.register(buildFgAppealTool())
47
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * focus-guard 适配层 · fg_apply 工具注册(3.0.5 第二步·入口一)
3
+ *
4
+ * 契约来源:装机 dsh-tools 的 defineTool —— 字段为
5
+ * defineTool({ name, description, parameters, output?, execute(args, exec), render?, presentCall? })
6
+ * 见 @deepseek-ai/dsh-tool-todo/lib/index.js:170 `execute(args, exec)`。
7
+ *
8
+ * 本文件只做薄壳:参数 → applyEligibility → 渲染文案。判定逻辑全在母版 checkEligibility,
9
+ * 授权表在 eligibility-gate 的 grantsFor(FG 自己的状态,不进 guard.mjs 的 state)。
10
+ */
11
+ import { defineTool } from '@deepseek-ai/dsh-tools'
12
+ import { applyEligibility, FG_APPLY_TOOL_SPEC, auditEligibility } from './eligibility-gate.mjs'
13
+ import { ABSOLUTE_REDLINES } from '../dsh/pipeline.mjs'
14
+
15
+ export const name = 'focus-guard-fg-apply'
16
+ /** 依赖注入:注册到 ctx.tools 即可,无需其它服务。 */
17
+ export const inject = ['tools']
18
+
19
+ /** 从 exec 取会话标识,口径与 audit.mjs 的 auditDeny 一致。 */
20
+ function sessionOf(exec) {
21
+ return (
22
+ (exec && (exec.sessionId || (exec.agent && (exec.agent.sessionId || exec.agent.id)))) || 'dsh-native'
23
+ )
24
+ }
25
+
26
+ /** 把审核结论渲染成模型可读文案。 */
27
+ export function renderDecision(result, spec) {
28
+ const layers = result.trace.map((s) => `L${s.layer}:${s.decision}`).join(' → ')
29
+ if (result.decision === 'allow') {
30
+ return `[资格审核·通过] ${spec.tool} 已获授权(ttl=${spec.ttl ?? 'turn'})。理由:${result.reason}\n层序:${layers}\n现在可以执行:${String(spec.command ?? '').slice(0, 120)}`
31
+ }
32
+ if (result.decision === 'needApproval') {
33
+ return `[资格审核·需人类审批] ${result.reason}\n层序:${layers}\n请向人类说明目的与影响范围后再执行;未经批准不得执行。`
34
+ }
35
+ return `[资格审核·拒绝] ${result.reason}\n层序:${layers}\n不得执行该命令;如需继续,先修正申请(目的/影响范围)或改用合规替代方案。`
36
+ }
37
+
38
+ /**
39
+ * 生成 fg_apply 的工具定义。redlines 注入而非内部 import,保持"母版/适配层不反向依赖"的可测性。
40
+ */
41
+ export function buildFgApplyTool({ redlines = ABSOLUTE_REDLINES } = {}) {
42
+ return defineTool({
43
+ name: FG_APPLY_TOOL_SPEC.name,
44
+ description: FG_APPLY_TOOL_SPEC.description,
45
+ parameters: FG_APPLY_TOOL_SPEC.parameters,
46
+ async execute(args, exec) {
47
+ const session = sessionOf(exec)
48
+ const spec = {
49
+ tool: args.tool,
50
+ command: args.command ?? '',
51
+ purpose: args.purpose,
52
+ scope: args.scope,
53
+ ttl: args.ttl ?? 'turn',
54
+ target: args.target,
55
+ }
56
+ const result = await applyEligibility({ session, ...spec, redlines, model: null })
57
+ return { text: renderDecision(result, spec) }
58
+ },
59
+ })
60
+ }
61
+
62
+ /** Cordis 插件入口:把 fg_apply 注册进 ctx.tools。 */
63
+ export function apply(ctx, config) {
64
+ void config
65
+ ctx.tools.register(buildFgApplyTool())
66
+ }
67
+
68
+ export { auditEligibility }
@@ -0,0 +1,42 @@
1
+ // FocusGuard 适配层 · DSH hook 协议(v3.0.5;《资料与代码分层总规范》二·2)
2
+ // stdin 载荷解析、会话 ID 提取、项目根定位。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
+
12
+ export function readStdinJson() {
13
+ try {
14
+ const raw = readFileSync(0, "utf8");
15
+ return raw.trim() ? JSON.parse(raw) : {};
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ export function sessionId(input) {
22
+ const id =
23
+ input.session_id ||
24
+ process.env.CLAUDE_SESSION_ID ||
25
+ process.env.ZCODE_SESSION_ID ||
26
+ "default";
27
+ const raw = String(id);
28
+ const safe = raw.replace(/[^A-Za-z0-9._-]/g, "_");
29
+ // 2.5.2:消毒会撞名(proj/a 与 proj_a → 同一状态文件,熔断/审批/预算跨会话串味)。
30
+ // 仅当发生替换时追加短哈希;正常 id 保持原样,不影响既有状态文件。
31
+ return safe === raw ? safe : safe + "-" + createHash("sha256").update(raw).digest("hex").slice(0, 8);
32
+ }
33
+
34
+ export function projectDir() {
35
+ const d = process.env.ZCODE_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || "";
36
+ if (!d) return null;
37
+ try {
38
+ return statSync(d).isDirectory() ? d : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
@@ -0,0 +1,64 @@
1
+ // FocusGuard 母版层 · 高危审批状态机(v3.0.5;《资料与代码分层总规范》二·1)
2
+ // 待批队列、批量出示、批示消费与目标绑定、阶梯提示。
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 { HIGH_RISK_QUEUE_MAX } from "./constants.mjs";
12
+
13
+ export function pushHighRiskPending(state, key, cmdBrief) {
14
+ if (!key) return;
15
+ state.highRiskQueue = state.highRiskQueue || [];
16
+ if (state.highRiskQueue.some((x) => x.k === key)) return;
17
+ if (state.highRiskQueue.length >= HIGH_RISK_QUEUE_MAX) return;
18
+ state.highRiskQueue.push({ k: key, c: String(cmdBrief || "").slice(0, 80) });
19
+ }
20
+ // 待批队列注记:除当前命令外还有几条在队列里,提示可合并批示
21
+ export function queueNote(state, currentBrief) {
22
+ const q = (state.highRiskQueue || []).filter((x) => x.c !== currentBrief);
23
+ if (q.length < 1) return "";
24
+ return `另有 ${q.length} 条待批已合并出示:${q.map((x) => x.c).join(";")}。回复 y 放行全部待批(各一次)、n 全部阻断。`;
25
+ }
26
+ // 授权消费(单条与批量统一):命中即消费一次,且两槽同时清除——否则批量 y 放行的命令
27
+ // 会在单条槽消费后仍留在批量槽里,同一命令被无声放行两次(一次性语义被破坏)
28
+ export function consumeHighRisk(state, key) {
29
+ // 3.0.5 目标绑定:批示 y 后该目标键本会话内持续有效——同目标重复触达不再重复弹单
30
+ // (修"一次一 attempt 消费制"被程序性拒绝烧掉批准、同目标反复弹单之痛,《审 2 号》同源)。
31
+ // 每次命中仍逐一记档 high-risk-executed;n 批示即解除绑定;键为全量哈希,命令变体不受豁免。
32
+ if (state.highRiskApprovedKeys && state.highRiskApprovedKeys[key]) {
33
+ // 绑定命中:同步清空单条槽,保持"绑定映射"为唯一授权事实源
34
+ state.highRiskOk = false;
35
+ state.highRiskCmd = "";
36
+ state.highRiskKey = "";
37
+ return "bound";
38
+ }
39
+ let hit = false;
40
+ if (state.highRiskOk && state.highRiskKey === key) {
41
+ state.highRiskOk = false;
42
+ state.highRiskCmd = "";
43
+ state.highRiskKey = "";
44
+ hit = true;
45
+ }
46
+ const batch = state.highRiskBatch || [];
47
+ const i = batch.indexOf(key);
48
+ if (i >= 0) {
49
+ batch.splice(i, 1);
50
+ state.highRiskBatch = batch;
51
+ hit = true;
52
+ }
53
+ return hit;
54
+ }
55
+
56
+ export function ladderNote(level) {
57
+ if (level >= 6) return "L6:已上报人类。";
58
+ if (level >= 5) return "L5:只读模式直至批示。";
59
+ if (level >= 4) return "L4:已记档。";
60
+ if (level >= 3) return "L3:等批示或降级方案。";
61
+ if (level >= 2) return "L2:下一调用必须取证。";
62
+ return "";
63
+ }
64
+
@@ -0,0 +1,71 @@
1
+ // FocusGuard 母版层 · 审计与因果链(v3.0.5;《资料与代码分层总规范》二·1)
2
+ // AUDIT.log 写入、seq/chain/ref 因果链上下文、假留痕防线(写失败一律上 stderr)。
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
+
12
+ export let auditFile = null;
13
+ export function auditTarget(sid) {
14
+ if (auditFile !== null) return auditFile;
15
+ // 2026-10-18 修(REPORT-2026-10-12 P1「审计单一入口未收敛」):
16
+ // 显式 FG_AUDIT_FILE 优先级最高。此前这里只认 ZCODE/CLAUDE 项目目录,于是
17
+ // 测试把 FG_AUDIT_FILE 指向 tmpdir 时,走核心 audit() 的那条路(risk.mjs penalize())
18
+ // 仍然写真实 AUDIT.log —— 隔离形同虚设,CHANGELOG 的"零增长"实测不成立。
19
+ const explicit = process.env.FG_AUDIT_FILE;
20
+ if (explicit) {
21
+ auditFile = explicit;
22
+ return auditFile;
23
+ }
24
+ const dir = process.env.ZCODE_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || "";
25
+ if (dir) {
26
+ try {
27
+ if (statSync(dir).isDirectory()) auditFile = join(dir, ".focus-guard", "AUDIT.log");
28
+ } catch {}
29
+ }
30
+ if (!auditFile) auditFile = join(tmpdir(), `focus-guard-${sid}-AUDIT.log`);
31
+ return auditFile;
32
+ }
33
+
34
+ // 3.0.0 因果链:auditCtx 随每次钩子调用初始化;seq=时间戳36进制.调用内序号(全局唯一),
35
+ // chain=任务链(reset 起算,子代理委派派生 /dN 子链),ref=父事件 seq——流水账由此可渲染为因果图。
36
+ export let auditCtx = { chain: null, base: 0 };
37
+ export function auditChainInit(state) {
38
+ auditCtx = { chain: (state && state.taskChain) || null, base: 0 };
39
+ }
40
+ export function audit(sid, trigger, opts = {}) {
41
+ const seq = Date.now().toString(36) + "." + ++auditCtx.base;
42
+ const record = JSON.stringify({
43
+ ts: new Date().toISOString(),
44
+ session: sid,
45
+ seq,
46
+ chain: opts.chain !== undefined ? opts.chain : auditCtx.chain,
47
+ ref: opts.ref || null,
48
+ action: opts.action || trigger,
49
+ trigger: opts.trigger ?? trigger,
50
+ level: opts.level ?? null,
51
+ evidence: String(opts.evidence || "").slice(0, 200),
52
+ pardon: !!opts.pardon,
53
+ }) + "\n";
54
+ // 2.5.1 假留痕防线:工作区写失败 → 回退系统临时目录;仍失败 → stderr 一行可见,绝不无声丢执法记录
55
+ for (const p of [auditTarget(sid), join(tmpdir(), `focus-guard-${sid}-AUDIT.log`)]) {
56
+ try {
57
+ mkdirSync(dirname(p), { recursive: true });
58
+ appendFileSync(p, record);
59
+ return seq;
60
+ } catch {}
61
+ }
62
+ noteFail(sid, "AUDIT.log(工作区与临时目录均写入失败)");
63
+ return seq;
64
+ }
65
+
66
+ export function noteFail(sid, what) {
67
+ try {
68
+ process.stderr.write(`[留痕告急]${what},会话 ${sid} 的本次记录未落盘。\n`);
69
+ } catch {}
70
+ }
71
+
@@ -0,0 +1,55 @@
1
+ // FocusGuard 母版层 · 改动前备份(v3.0.5;《资料与代码分层总规范》二·1)
2
+ // .ai/backup/ 物理回滚副本,保留最近 BACKUP_KEEP 份;敏感文件不落明文。项目根由参数传入。
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 { SHA_LIMIT, BACKUP_KEEP, SECRET_FILE_RE } from "./constants.mjs";
12
+ import { normalize } from "./state.mjs";
13
+ import { audit, noteFail } from "./audit.mjs";
14
+ import { sid } from "./session.mjs";
15
+
16
+ export let backupSeq = 0; // 2.5.2:备份文件名加进程号+自增序号,避免同一毫秒内两次备份互相覆盖
17
+ // ============ 2.2.0 正面指引(一.2):改动前自动备份 ============
18
+ // 回滚按环境自动选:有 .git → git restore;没有 → 本函数产出的 .ai/backup/ 物理副本覆盖还原。
19
+ // 备份改动前的现状,保留最近 BACKUP_KEEP 份(超出淘汰最旧)。>200KB 的文件有意跳过(避免拖慢大写入,
20
+ // README 已注明此上限);失败不阻断执法,但一律上 stderr——静默会让 AI 误以为存在可回滚副本(2.5.1 假留痕防线)。
21
+ export function backupBeforeEdit(absPath, projDir) {
22
+ try {
23
+ const dir = projDir;
24
+ if (!dir) return;
25
+ const st = statSync(absPath);
26
+ if (!st.isFile() || st.size > SHA_LIMIT) return;
27
+ const rel = relative(dir, absPath);
28
+ if (!rel || rel.startsWith("..")) return;
29
+ // 2.5.2 敏感文件不落明文副本:.env/私钥/凭据一旦复制进 .ai/backup/,等于在工作区里多留若干份明文密钥。
30
+ // 跳过备份并留痕 + 明确告知,避免 AI 误以为存在可回滚副本。
31
+ if (SECRET_FILE_RE.test(normalize(absPath))) {
32
+ audit(sid, "backup-skip-secret", { level: null, evidence: `敏感文件不落明文副本 ${rel}(回滚请用版本控制)` });
33
+ process.stderr.write(`[备份跳过]${rel} 属敏感文件,不复制明文副本;回滚请用版本控制。\n`);
34
+ return;
35
+ }
36
+ const root = join(dir, ".ai", "backup");
37
+ const dest = join(root, rel + "." + Date.now().toString(36) + process.pid.toString(36) + (backupSeq++).toString(36) + ".bak");
38
+ mkdirSync(dirname(dest), { recursive: true });
39
+ copyFileSync(absPath, dest);
40
+ const all = [];
41
+ (function walk(d) {
42
+ for (const e of readdirSync(d, { withFileTypes: true })) {
43
+ const p = join(d, e.name);
44
+ if (e.isDirectory()) walk(p);
45
+ else all.push([p, statSync(p).mtimeMs]);
46
+ }
47
+ })(root);
48
+ if (all.length > BACKUP_KEEP) {
49
+ all.sort((a, b) => a[1] - b[1]);
50
+ for (let i = 0; i < all.length - BACKUP_KEEP; i++) rmSync(all[i][0], { force: true });
51
+ }
52
+ } catch {
53
+ noteFail(sid, `改动前备份 ${absPath}(无副本可回滚,改动仍会放行)`);
54
+ }
55
+ }