Documentation
¶
Overview ¶
Package archcheck 是 architecture-policy.yaml 的**可执行面**。
为什么它是一个可导入的包,而不是一个放在 cmd/ 下的脚本 ¶
存在意义:policy 文件若只当文档读,半年后就与代码事实脱节;把它做成门禁, 规则违反即非零退出。但门禁执行体若锁在 package main 里,别的仓就**用不上**—— 于是每个宿主仓要么各写一遍判据(各判一套,漂移到没人发现为止),要么干脆不写 (policy 的依赖拓扑表变成死条文:躺在文件里、看起来有人维护过,实际一条都不判)。
所以这一层被下沉成包:宿主仓 import 它、喂自己的 policy、拿自己的判定结果, 不复制一行实现。
判据与实现分离 ¶
判据是**声明**(policy 文件)+ **执行体**(本包),两者分开:
- 声明面:modules 段的依赖拓扑、rules 段的正则、facades 段的门面包清单
- 执行面:本包的 AST 判定(go/ast 判 import、判函数体、判依赖方向)
结构性判定一律走 go/ast,不走逐行正则——import 声明有六种以上书写形态 (单行 / 块 / 别名 / 空白导入 / 点导入 / 括号同行),逐行正则必然漏, 且漏得毫无声响。
门禁的失败形态是「变绿」,不是「变红」 ¶
判据写错时最危险的不是报错,而是**什么都不判**却照样报「无阻断级违反」。 故本包把「给不出结论」与「没违规」严格分开:解析失败、policy 自相矛盾、 规则没有执行体——一律响亮失败(返回 error 或报成违反),绝不静默降级。
复用方须知 ¶
- policy 的 modules 段要求每个 root 指向**真实存在**的包目录:陈旧的 root 会被判成违反(「陈旧包根当天即红」),因为它让该模块对门禁隐形。
- 跨仓依赖用 `ext:<import 路径前缀>` 表达,如 `ext:github.com/yi-nology/goagent-kernel/netguard`。
- 报告用 WriteReport;是否阻断用 Result.HasBlocking。
调用面禁令与路径白名单怎么表达 ¶
policy **没有** `forbid_call_regex` / `allow_paths` 字段——判据面只有两种: **import 路径**(`forbid_import_regex`)与**源码文本**(`forbid_regex`)。 常见的两种诉求用现有字段就能表达:
| 诉求 | 表达方式 | |---|---| | 「某个第三方包只允许经某一个适配层引入」(路径白名单)| `forbid_import_regex` + `scope_exclude: [适配层目录]` | | 「某目录禁止裸 fetch(,但 api 客户端目录与两个 SSE 钩子除外」(调用面禁令 + 路径白名单)| `forbid_regex`(匹配调用形态)+ `scope_exclude`(豁免目录)|
为什么不再加第三种判据面:每多一种判据面,policy 作者就要多记一套豁免语义 (负向前置的锚点写在哪、目录豁免算不算……)。本包已经因为「同一字段在两个分支 语义不同」吃过一次亏(import 分支不认 `negative_follow`,静默假红),那次的 修法是**让两个分支同构**,而不是再加一种。
egresspaths.go —— 子进程出口的**声明面一致性**判据(P0-2 的后半)。
它拦的失效形态 ¶
@egress@ 给的是出口策略的**词表与判据**(env 生成、Verify、后端能力问询), 但词表本身不会跑到任何一个子进程上去。子进程一旦被 spawn,它的出站流量就 完全在本进程的 netguard 之外——「声明了受限出口」在那条路径上**没有任何东西 兑现**,而报告是全绿的,因为没有任何判据在问「这个 spawn 点在强制面内吗」。
这正是本仓反复强调的「声明强度 ≠ 实际强度」在执行面上的又一次实例: 判据停在工具调用,工具内部起的进程没有判据。
判据五条 ¶
- **每个子进程启动点必须登记**(未登记即红)。方向是 fail-closed: 「没登记」不等于「不要求出口」,而是**没人声明过它的出口是什么**—— 与 @sandbox_providers@ 的「有实现没登记必须报出来」同一条纪律。
- **登记的兑现必须能看见,且是两半**(mode >= proxy 时,含启动的那个函数里 既要出现出口 env 的生成/校验调用,**也要**出现后端能力的问询调用)。 只看得见 env 生成时,「声明了但没人执行」这一态是拦不住的:环境变量只是 **建议**,一个能改 env 却没有任何强制面的后端照样让流量自由出网,而报告里 「生成了 env」看起来就是「已兑现」。能力问询(SatisfiedBy)是那一半: 它问的是「你**能强制**这一档吗」,问不到即不起子进程。
- **禁网档不许落在环境变量上**(mode: denied 直接红)。环境变量拦不住 静态二进制、UDP 与原始 socket,登记成 denied 只是把「假装禁网」写进 policy——比明确不声明更危险。
- **陈旧登记当天即红**(文件/函数不存在,或那个函数里没有启动调用)。 与「陈旧包根当天即红」同一把尺子:声明与事实偏离而无人红,是无声漂移。
- **启动文件不得把全量环境交给子进程**(os.Environ() / procx.InheritAllExcept 即红,不看登记)。 登记段回答「出站策略是什么」,本条回答「子进程能看见宿主的哪些秘密」—— 两者正交:unrestricted 的启动点同样不该继承整份环境。详见 inheritsFullEnv。
为什么按「文件 + 函数」定位启动点 ¶
按包名太宽(一个包里可能既有 spawn 也有不 spawn 的函数),按行号太脆 (改一行注释就漂)。文件 + 函数是**改动的最小稳定单元**:函数搬走或改名, 登记就该跟着改,改漏了第 4 条会红。
判定面包含哪些启动形态 ¶
收集器在 spawnsites.go,覆盖三种形态(每一种都实测过漏判):限定符调用(含别名)、 点导入(无限定符)、**包级初始化**(var cmd = exec.Command(...),不在任何函数里)。 入口表按 import 路径归:除 os/exec 还含 os.StartProcess 与 syscall.Exec/ForkExec/StartProcess——它们同样绕开出口强制面,也各自接受 Env, 故「登记 + 兑现证据」这套判据对它们逐字适用。
判不了什么(写明,而不是假装覆盖) ¶
- 判不了「env 真的被子进程遵守」:环境变量只是建议,这是 egress 包 包头已写明的边界,本门禁不重复声明它能做到。
- 判不了「围绕 cmd 包了一层自己的 spawn 工具再调用」:那需要类型的推导 或跨函数数据流,门禁不 import 被判的包也就拿不到类型信息。 这类封装最终仍要落到某个 os/exec 调用上,故**启动点**这一层是收口的; 但如果宿主把 spawn 挪进第三方库,本门禁看不见。
egressprobe.go —— 出口兑现的**调用证据**判定(AST 走法)。
与 egresspaths.go 分文件是因为两个关注点:那边判「登记与事实是否一致」, 这里判「函数体里有没有那**两个**调用」。合并会让 egresspaths.go 撞上 max_file_lines,而按行随便切一刀又会让「怎么认证据」这件事散落两处。
判据认结构,不认文本 ¶
三种书写形态都能命中:包限定(\`egress.Env(...)\`)、接收者调用(\`spec.Env(...)\`, 前提是本文件 import 了 egress)、以及 SatisfiedBy 的同款形态。逐行正则只认第一种, 而绕过去不需要动机——只需换个导入写法。
execution.go —— 执行路径门禁:执行必须经 `sandbox` 登记簿。
这条门禁拦的失效形态 ¶
上游 `sandbox` 的核心语义是「选不到满足档位的后端即拒绝,**绝不降级**」, 且「兜不住预算的后端不被选中」。这两条由 `Registry.Resolve` 保证。
但**执行路径**可以完全绕开登记簿:宿主直接调后端的裸执行入口 (本仓的 `ptc.Run`),于是——
- 「要求了 OS 级隔离」却跑在进程内解释器上;
- 「要求了内存兜底」而后端根本兜不住;
- 审批门(`gatecheck.Managed`)整层被跳过。
而**门禁全绿**:档位登记门禁只核对「登记 ↔ 代码事实」,不核对 「**实际执行用的后端**是不是登记过的那个」。说谎的不是档位表,是**执行路径**。
判据 ¶
扫生产代码里的**限定符调用** `pkg.Fn(...)`:命中**裸执行入口**登记 ⇒ 违规。
为什么按「包名 + 符号」匹配,而不是按完整 import 路径 ¶
- **别名改不掉**:按 import 路径匹配时,宿主 `import p "…/ptc"` 换个别名就 绕过了。按 import 路径的**最后一段**(包名)匹配,别名不影响命中。
- **module 路径迁移不会陈旧**:本仓的 module 路径迁移过一次 (私有域名 → github.com)。若内置表写死完整 import 路径,迁移那天 忘了改门禁 ⇒ 判定**静默失配**(本仓纪律明令禁止写死 module 路径)。 按包名匹配则与 module 路径无关。
需要更精确时,登记项可填 `import_path` 二次收紧(限定到某一个具体模块)。
为什么同包调用不需要豁免 ¶
同包内的调用**没有限定符**(`Run(...)` 而不是 `ptc.Run(...)`)⇒ 根本不会被 扫到。实现内部的调用(`ptc.Provider.Exec` 调 `Run`)天然不构成绕过。
为什么裸入口是**内置表**而不是每个仓各登记一份 ¶
内置表记的是**本仓自己的 API 事实**(`ptc.Run` 不经登记簿)。 让每个仓在自己的 policy 里各登记一遍,就是**两份真相**——宿主改了登记、 本仓没改,两边各判一套,漂移到没人发现为止(且**没有任何信号**:门禁都绿)。
故:内置表(本仓的事实,宿主白拿)+ policy 段(宿主自己的事实)**并集**判。
这条门禁判不了什么(写明,而不是假装覆盖) ¶
判不了「持有后端实例直接调 `provider.Exec(...)`」——那要判**接收者的静态类型**, 而 AST 拿不到变量的类型(要 `go/types` 才能推导)。 门禁不能 import 被判的包,也就不能靠类型信息定位调用点。 故:这类绕过**不在判定面内**,靠人工 review 与接线纪律兜。
importpattern.go —— forbid_import_regex 的「结构上命中不了」判据。
为什么需要它 ¶
forbid_import_regex 的判据面是 **import 路径**(imports.go 的 importViolationsIn), 而 Go 的 import 路径永远是 clean 形式:go 命令拒绝 a//b,也不存在以 / 结尾的 包路径。于是某些正则形状**一条都匹配不到**——它仍然是一条规则:计入「N 条规则」、 挂着 severity: error、selfcheck 只问「两个正则是否都为空」,于是它永不触发而 门禁照常绿(issue #41)。
判据为什么走 regexp/syntax 而不是 strings.Contains ¶
最省事的修法是 strings.Contains(pattern, "//"),但它两头都错:
- **假阳性**:[//] 是**字符类**(匹配单个 '/'),文本里含 '//' 却完全合法;
- **假阴性(更要命)**:issue #41 自己举的例子 …/internal/(/|$) 里 **没有相邻的两个斜杠**——它是「前缀以 / 收尾」+「边界组 (/|$)」拼出来的 **语义上的** '//'。按文本判,这条规则会被判成健康,issue 就算「修好了」, 而缺陷原封不动地躺在仓里。
故判定面是语法树:判的是**结构**(字面量里是否强制相邻两个 '/'、结尾分支 是否要求以 '/' 收尾),不是文本长相。这也与仓内既有的纪律同源:结构性判断 走 AST,不走逐行正则。
判据的边界:只判「可证明永不命中」 ¶
本文件只报**能证明**永不命中的形态:返回空串**不等于**这条规则健康。 「这条规则还活着吗」的语义证明仍归 policy 的 examples 管 (require_rule_examples),两者分工明确:这里防「斜杠写重了」, 样例防「词表被改名 / 包被迁走」。反过来若在这里追求「一定命中」, 判据就变成不可判定的问题,只能靠误报凑数。
policy_egress.go —— egress_paths 段的声明面类型。
单列一份是因为 policy.go 已经贴着 max_file_lines:把「声明长什么样」与 「其余各段的声明」拆开,改动面也更清楚(这一段与 execution_paths 同族, 判据在 egresspaths.go)。
sandbox.go —— 执行沙箱后端的**档位声明**核对(policy 的 sandbox_providers 段的执行体)。
这条门禁拦的失效形态 ¶
执行后端自报隔离档位(Provider.Level())。若那份声明只活在代码里,它有三个问题:
- **看不见**:review 时看不到「这个后端声称自己有多强」——档位是实现里的一个 常量,混在一堆方法之间,读者不会专门去看它。
- **漂移**:改了代码里的档位,声明面没有跟着变,而**没有任何东西会响**。
- **说谎没人管**:后端标了比实际更强的档位(例如「内核级」其实只是进程内解释器), 宿主据此做的风险判断全部落空——构建全绿、测试全绿、审计日志里只有一条 普通的进程记录。
怎么把信任边界写死 ¶
policy 的 sandbox_providers 段是**声明面**:每个后端一条登记(标识、档位、所在包), 档位填 `sandbox` 包的档位常量名(LevelNone / LevelProcess / LevelOS / LevelMachine)。 人 review 时看得到这一行,「它凭什么说自己有这一档」写在该条的 notes 里。
执行体核对两件事(都走 go/ast,不逐行看文本):
- **登记 ↔ 代码事实一致**:登记的标识与档位必须在该包的后端实现里对得上; 对不上就是漂移(代码改了、声明没改)。
- **实现 ↔ 登记齐全**:本仓每个后端实现都必须在 policy 里登记。未登记的后端, 它的档位声明**不在被复核的清单里**——它可以存在,但不能假装自己被复核过。
这条门禁判不了什么(写明,而不是假装覆盖) ¶
它判不了「后端实际提供的隔离强度是否兑现其声明」——那要审实现,机械判不了。 它能保证的是三件事:声明**可见**(在 policy 里)、**不漂移**(门禁核对)、 **有签字**(人 review 时看到那一行)。
为什么用方法指纹判定「这是个后端实现」 ¶
判据是「实现了 ID + Level + Exec 三个方法」,**不是**「它 import 了 sandbox 包」。 后者只说明它见过那个包,前者才说明它实现了那个协议;而 import 路径在本仓与宿主仓 不同,写死路径会让复用方永远判不出来——于是这条门禁在宿主仓就成了摆设。
spawnsites.go —— 子进程启动点的收集(出口门禁的「事实面」)。
判据(登记与否、兑现是否可见)在 egresspaths.go;这里只回答一个问题: **代码里到底有哪些地方起了子进程**。拆成两份是因为两类失效的形态不同: 判定写错会误报(有人会来修),收集写漏是**静默放行**(没人会来)——后者必须在 自己的文件里被逐形态钉住。
覆盖的三种形态(每一种都是实测漏判后才补上的) ¶
- **限定符调用**:exec.Command(...)。别名(import ex "os/exec" ⇒ ex.Command) 同样命中——限定符按 **import 路径**解析,不按名字猜。
- **点导入**:import . "os/exec" ⇒ Command(...) 没有限定符。按「本文件点导入了 哪个启动包」补齐,否则换个导入写法就能让判定面消失。
- **包级初始化**:var cmd = exec.Command(...) 不在任何函数里。旧实现只走 *ast.FuncDecl,于是把 spawn 挪到包级变量就能让门禁变绿——而 spawnSite.fn 的 注释早就写着「空 = 函数外,如包级变量初始化」,即**承诺了却没人实现**。
入口表按 import 路径归,不止 os/exec ¶
os.StartProcess 与 syscall.Exec/ForkExec/StartProcess 同样绕开出口强制面。 它们各自接受 Env,故「登记 + 兑现证据」这套判据对它们逐字适用——把它们留在 判定面之外,等于承认「换个 API 就能绕过登记」,而绕过是静默的。
Index ¶
- func FindRoot(start string) (string, error)
- func PolicyPath(root string, pol *Policy) string
- func WriteReport(w io.Writer, res *Result, opts ReportOptions) error
- type DependencyDirection
- type EgressEntry
- type EgressPaths
- type Exception
- type ExecutionEntry
- type ExecutionPaths
- type FacadeRule
- type Module
- type NegFollow
- type Policy
- type ReportOptions
- type Result
- type Rule
- type RuleExample
- type RuleInfo
- type SandboxProvider
- type SandboxProviders
- type Threshold
- type Violation
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func FindRoot ¶
FindRoot 从 start 向上查找含 architecture-policy.yaml 的目录。
为什么向上找而不是要求调用方传绝对路径:门禁要在这个目录下判定所有包, 而包在磁盘上的位置是随仓库克隆位置变的。
func PolicyPath ¶
PolicyPath 归一 policy 文件路径(相对仓库根),供报告与自校验定位。
func WriteReport ¶
func WriteReport(w io.Writer, res *Result, opts ReportOptions) error
WriteReport 把结果写成人类可读的报告。
报告形状与「哪些规则在什么范围内」无关——三类规则(rules 段正则、facades 段、 modules 段 requires)的回显形状完全一样,复制三份意味着以后改截断口径要记得改三处。
返回写入错误:报告写了一半就失败(管道提前关闭、磁盘满),调用方需要知道 「这份报告不完整」——否则它会拿一份残缺的报告当结论。
Types ¶
type DependencyDirection ¶
type DependencyDirection struct {
Enabled *bool `yaml:"enabled"` // 指针:缺省即启用,只有显式 false 才关
Severity string `yaml:"severity"`
Desc string `yaml:"desc"`
IgnoreDirs []string `yaml:"ignore_dirs"`
// ExtPrefixes 是**受管外部包面**清单:这些 import 路径前缀下的外部包,
// 全部纳入本门禁的判定面。
//
// 为什么要有这一层(policy 级),而不能只靠各模块 requires 里写的 ext: 条目:
// 若「哪些外部包受管」本身也只由各模块自己声明,那么**最后一个声明它的模块
// 一旦撤掉声明,整片包面就随之消失**——那些 import 重新变成不判定的。
// 而外部包没有「未登记目录」那道兜底(它是别的仓的包,本仓没有它的目录),
// 于是这条边就静默失管了:门禁变绿,而不是变红。
//
// 这一层与内部模块的结构同构:
// - 受管面:modules 段(内部)/ ext_prefixes(外部)
// - 逐模块的边:requires
ExtPrefixes []string `yaml:"ext_prefixes"`
}
DependencyDirection 是 dependency_direction 段的配置:依赖方向门禁的开关、 级别、豁免目录与**受管外部包面**。
为什么不塞进 modules 段:modules 段是**逐包**的声明,依赖方向是**跨包**的 规则,混在一段里会让「这条登记的字段」与「整个文件的配置」两种粒度互相干扰。
type EgressEntry ¶
type EgressEntry struct {
// Path 启动点所在文件(相对仓根,正斜杠)。
Path string `yaml:"path"`
// Fn 含启动调用的函数名(空 = 该文件内全部启动点)。
Fn string `yaml:"fn"`
// Mode 该启动点的出口档位:unrestricted / proxy / denied(缺省 unrestricted,
// 但**仍须显式登记**——「没登记」不等于「不要求」)。
Mode string `yaml:"mode"`
// Plumbing 兑现证据的函数名(缺省 Env / Verify)——「这一档落成 env」那一半。
Plumbing []string `yaml:"plumbing"`
// Capability 能力问询证据的函数名(缺省 SatisfiedBy)——「这一档有人强制」那一半。
//
// 两半分开可配而不是并成一个列表:它们是**两条**判据,报错要说清缺哪一半
// (加 Env 与加后端是两种完全不同的修法)。
Capability []string `yaml:"capability"`
// Notes 为什么是这个档(写给人看的那一句)。
Notes string `yaml:"notes"`
}
EgressEntry 是 egress_paths 段里的一条**子进程启动点**登记。
定位粒度是「文件 + 函数」:按包名太宽(一个包里可能既有 spawn 也有不 spawn 的 函数),按行号太脆(改一行注释就漂)。函数搬走或改名,登记就该跟着改, 改漏了判据会报陈旧。
type EgressPaths ¶
type EgressPaths struct {
Enabled *bool `yaml:"enabled"`
Severity string `yaml:"severity"`
Desc string `yaml:"desc"`
IgnoreDirs []string `yaml:"ignore_dirs"`
Entries []EgressEntry `yaml:"entries"`
}
EgressPaths 是 egress_paths 段的配置:开关、级别、受忽略目录与启动点清单。
形态与 execution_paths 段同构:都是「声明面 + 代码事实」的一致性判据。 差别在于执行路径门禁有**内置表**(记本仓自己的 API 事实),而这一段没有: 子进程启动点是每个仓自己的事实,本仓没有资格替宿主登记。
type Exception ¶
type Exception struct {
Rule string `yaml:"rule"`
Path string `yaml:"path"`
Line int `yaml:"line"`
Violation string `yaml:"violation"`
Reason string `yaml:"reason"`
Until string `yaml:"until"`
Removal string `yaml:"removal"`
}
Exception 是例外登记:精确豁免某条规则的某处违反。
豁免必须写 until(到期日)与 removal(拆除条件)——没有到期日的豁免等于永久豁免, 没有拆除条件的豁免是赖账。两者都由 policy 自校验强制。
type ExecutionEntry ¶
type ExecutionEntry struct {
// PackageName 入口所在包的**包名**(import 路径的最后一段,如 `ptc`)。
PackageName string `yaml:"package_name"`
// Symbol 入口的符号名(包级函数)。
Symbol string `yaml:"symbol"`
// ImportPath 可选:填了则 import 路径必须**逐字相等**(更精确)。
ImportPath string `yaml:"import_path"`
// Notes 该入口为什么是裸的(写给人看的那一句)。
Notes string `yaml:"notes"`
}
ExecutionEntry 是 execution_paths 段里的一条**裸执行入口**登记。
「裸」指什么 ¶
不经 `sandbox` 登记簿就能跑起一段程序的入口。绕开登记簿意味着三样东西 同时失效:**档位下限**(`Resolve` 选不到就拒绝的那条)、**预算可兑现性** (兜不住内存的后端不被选中的那条)、**审批门**(`gatecheck.Managed` 那层)。
而这三条恰恰是宿主据以做风险判断的依据——声明还在,强制没了。
为什么按**包名**登记而不是完整 import 路径 ¶
- **别名改不掉**:按完整 import 路径匹配时,宿主 `import p "…/ptc"` 换个别名 就绕过了。按包名匹配,别名不影响命中。
- **module 路径迁移不会陈旧**:本仓的 module 路径迁移过一次 (私有域名 → github.com),写死完整路径会在迁移那天静默失配。
需要更精确时填 `import_path` 二次收紧(限定到某一个具体模块)。
type ExecutionPaths ¶
type ExecutionPaths struct {
Enabled *bool `yaml:"enabled"`
Severity string `yaml:"severity"`
Desc string `yaml:"desc"`
IgnoreDirs []string `yaml:"ignore_dirs"`
RawEntries []ExecutionEntry `yaml:"raw_entries"`
}
ExecutionPaths 是 execution_paths 段的配置:开关、级别、受忽略目录与裸入口清单。
形态与 sandbox_providers 段同构(配置 + 逐条声明的表)。
本仓自己的裸入口不在这一段里 ¶
它们在判据执行体的**内置表**里(见 execution.go):内置表记的是本仓的 API 事实, 让每个仓各登记一遍就是两份真相。policy 段只登记**宿主自己的**裸入口。
type FacadeRule ¶
type FacadeRule struct {
Package string `yaml:"package"`
Desc string `yaml:"desc"`
Severity string `yaml:"severity"`
Upstream []string `yaml:"upstream"` // 允许作为转发目标的 import 路径
AllowedImports []string `yaml:"allowed_imports"` // 签名所需的额外 import(不含 upstream)
}
FacadeRule 声明一个门面包:它转发到哪、还允许带哪些签名所需的额外依赖。
type Module ¶
type Module struct {
ID string `yaml:"id"`
Roots []string `yaml:"roots"`
Requires []string `yaml:"requires"`
Owner string `yaml:"owner"`
Managed bool `yaml:"managed"`
Notes string `yaml:"notes"`
}
Module 是 modules 段的一条登记:roots 定位包目录,requires 声明**允许**的 依赖方向。
为什么这段必须被真的执行:requires 是一张包之间的依赖拓扑声明,写错或漏写时 它不会自己报警——半年后没人知道它已经不成立,而依赖缠绕恰好是最难靠 review 发现的债类型(零报错、零警告、构建全绿)。
requires 的条目有两种形态:
- 模块 id:本仓内部模块,必须在 modules 段登记过,否则声明自相矛盾(判为 policy 解析失败,不静默降级)。
- ext:<import 路径前缀>:外部包面,用于表达「本模块依赖某个外仓的哪个包」。 机制面迁到外仓之后,宿主的依赖拓扑表需要能表达跨仓依赖,而只认本仓模块 id 的判据表达不了。
type NegFollow ¶
NegFollow 是负向前置的结构化表达:anchor 紧跟 forbid_follow 时,命中位置 (被禁词)落在 forbid_follow 里即豁免。
为什么不写进正则:Go 原生只支持 RE2,没有后顾断言((?!))。
type Policy ¶
type Policy struct {
Version int `yaml:"version"`
Modules []Module `yaml:"modules"`
Rules []Rule `yaml:"rules"`
Facades []FacadeRule `yaml:"facades"`
DepDir DependencyDirection `yaml:"dependency_direction"`
Thresholds Threshold `yaml:"thresholds"`
Exceptions []Exception `yaml:"exceptions"`
// SandboxProviders 是执行沙箱后端的档位登记段(声明面)。
//
// 指针而非值:段整体缺省时,**门禁仍然开启**——因为「本仓有后端实现却没登记」
// 必须报出来,而不是「没登记就当没这回事」。指针只用于区分「段不存在」与
// 「段存在但为空」,两者的判定结果相同(都要靠代码事实说话)。
SandboxProviders *SandboxProviders `yaml:"sandbox_providers"`
// ExecutionPaths 是执行路径门禁的声明面(裸执行入口清单)。
//
// 段缺省即启用——「执行必须经登记簿」是**默认要求**,
// 不是「登记了才管」。没登记的裸入口不会被这条门禁抓到(判据面有限,
// 见 execution.go 的「判不了什么」),但默认开着总比默认关着好。
ExecutionPaths *ExecutionPaths `yaml:"execution_paths"`
// EgressPaths 是子进程出口门禁的声明面(逐个启动点登记出口档位)。
//
// 段缺省即启用:「子进程在不在出口强制面内」这个问题必须有人问,
// 而不是「宿主登记了才问」——不登记就没人问,正是这条门禁要防的静默失效。
EgressPaths *EgressPaths `yaml:"egress_paths"`
// RequireRuleExamples 要求每条**有执行体**的规则都自带 examples。
//
// 默认 false:宿主仓不必为了升级判据而先给全部规则补样例(那会让一次
// 依赖升级变成一次策略改写)。本仓自己开 true——口径是「本仓的每条规则
// 都必须能被加载期验活」,而这条口径若只写在注释里就是散文,不是门禁。
RequireRuleExamples bool `yaml:"require_rule_examples"`
// Path 记录 policy 文件路径。自校验违规要指向它,而宿主仓的 policy
// 不一定叫 architecture-policy.yaml——写死文件名等于让复用方永远看到
// 一个指向不存在文件的报错位置。
Path string `yaml:"-"`
}
Policy 是 architecture-policy.yaml 的反序列化结果。
type ReportOptions ¶
type ReportOptions struct {
// Full 为真时输出全部违反,否则每条规则截断到 10 条。
Full bool
}
ReportOptions 控制报告输出形状。
type Result ¶
type Result struct {
// Violations 是全部违反(含警告级与已命中例外的)。
Violations []Violation
// Blocking 是阻断级违反数,Warnings 是警告级数。
Blocking int
Warnings int
// Excused 是命中已登记例外(且未到期)的违反数。
Excused int
// ExpiredExceptions 是已到期的例外——它们不再豁免任何东西。
ExpiredExceptions []string
// ThresholdFindings 是结构阈值(超长文件 / god object)的发现。
ThresholdFindings []string
// ThresholdEnforced 报告阈值是否阻断。
ThresholdEnforced bool
// Rules 是报告顺序下的规则清单。
Rules []RuleInfo
// Exceptions 是 policy 登记的例外清单(回显用,让债是显式的)。
Exceptions []Exception
// contains filtered or unexported fields
}
Result 是一次门禁运行的结果。
func Run ¶
Run 对 root 执行全套门禁,返回结果。
返回 error 表示门禁**给不出结论**(policy 解析失败、源码解析失败、声明自相矛盾)—— 调用方必须据此非零退出,绝不能当成「无违反」继续。
func (*Result) HasBlocking ¶
HasBlocking 报告本次运行是否应当以非零退出码阻断。
阈值门禁只在 enforce 为真时阻断——它是「报告口径」还是「真阈值」由 policy 决定。
type Rule ¶
type Rule struct {
ID string `yaml:"id"`
Desc string `yaml:"desc"`
Severity string `yaml:"severity"` // error(默认,违反即非零退出)| warn(报告口径,不阻断)
Scope []string `yaml:"scope"`
ScopeExclude []string `yaml:"scope_exclude"`
Include []string `yaml:"include"`
IgnoreTest bool `yaml:"ignore_test"`
OnlyTest bool `yaml:"only_test"` // 只扫测试文件(配合 severity: warn 做夹具体检)
ForbidImport string `yaml:"forbid_import_regex"`
ForbidRegex string `yaml:"forbid_regex"`
NegFollow []NegFollow `yaml:"negative_follow"`
// ImportExempt import 路径级的**精确**例外(issue #15)。
//
// 存在的原因:只有 `forbid_import_regex`(正则禁令)时,宿主若要放行
// 被禁依赖树里的**某个具体子包**,只有三条路:
// 1. 放宽整条正则 ⇒ 把整棵树的其余部分一起放行;
// 2. 改成逐包白名单 ⇒ 上游将来新增一个包就**静默放行**
// (一条被静默放宽的声明等于没有声明);
// 3. 绕开 —— 在宿主侧自写一层转译。
//
// 第 3 条可行,但代价是同一个上游能力要在宿主侧再写一遍转译
// (类型不许外泄、等价性自己核)——而这层转译本可以由上游的一个
// import 例外免掉。
//
// 粒度必须是**精确 import 路径**(不是正则前缀——后者等于放宽整棵树)。
// 未登记的包**仍然被拒** ⇒ 上游新增包不会静默放行。
ImportExempt []string `yaml:"import_exempt"`
// Examples 规则自带的样例:**加载期**就判「这条规则还活着吗」。
//
// 与 tests 的分工:测试证明「今天这条规则能判」,examples 证明「这次加载
// 这条规则确实判得动」——前者在 CI 里跑一次,后者在**每一次**门禁运行、
// 每一个复用本判据的宿主仓里跑。规则会随包名/命令名/词表漂移,而漂移
// 不会报错:它只是静静地不再命中,门禁继续绿着。
//
// 判据复用**真实判定核**(regexViolationsIn / importViolationsIn,与文件遍历
// 调的是同一份代码),不是另写一套近似实现——另写一套的话,样例过了也说明不了
// 真判定能过(两套实现各自漂移,而样例那套只看几个字符串、漂得更慢)。
Examples []RuleExample `yaml:"examples"`
}
Rule 是一条正则式规则:匹配面由 scope/include/scope_exclude 圈定,判据由 forbid_regex(源码文本)或 forbid_import_regex(import 路径)给出。
type RuleExample ¶
type RuleExample struct {
// Text 样例(源码行或 import 路径)。
Text string `yaml:"text"`
// Want 期望:hit = 必须被判违规;miss = 必须不被判违规。
Want string `yaml:"want"`
}
RuleExample 一条规则的样例。
Text 的**判据面**随规则类型而定,与真实判定同构:
- forbid_regex 规则:Text 是一行源码;
- forbid_import_regex 规则:Text 是一条 import 路径(不含引号)。
Want 只接受 hit / miss 两个值。**拼写错误必须报错**:把 want 写成 hitt 时 若按「非 hit 即 miss」处理,一条判据彻底坏掉的规则会因为「反例都通过」而绿。
type RuleInfo ¶
RuleInfo 是报告用的一条规则元信息(ID、说明、级别)。
为什么单独一个类型:facades 段与 modules 段的规则**不在 rules 段里**, 它们没有 policy 里的 Rule 结构可复用,但报告形状必须一致。
type SandboxProvider ¶
type SandboxProvider struct {
ID string `yaml:"id"`
Level string `yaml:"level"`
Package string `yaml:"package"`
// Supports 登记该后端能兑现哪些预算项。
//
// 取值是上游 `guardx/sandbox` 的预算项名:wall_clock / max_calls /
// max_bytes / max_memory。缺省(未填)= 只兑现基本三闸——
// 与上游「不实现 Capable 的后端只兑现三闸、不兑现内存」的缺省语义一致。
//
// 为什么登记这一维:档位(Level)答的是「能力面多强」,预算支持面答的是
// 「资源兜底兑现哪几项」。两者**正交**——进程内解释器的能力面与
// LevelProcess 同级,却兜不住内存。少了这一维,「要求了内存兜底而后端
// 兜不住」这件事在声明面是隐形的,宿主只能在运行期才发现。
//
// 门禁核对的是「登记与代码事实一致」:后端**没有** `Supports` 方法 ⇒ 只能
// 登记基本三闸;后端**有** `Supports` 方法 ⇒ 可以登记含 max_memory。
// 判据同样走 `go/ast` 的方法指纹(门禁不能 import 被判的包)。
Supports []string `yaml:"supports"`
Notes string `yaml:"notes"`
}
SandboxProvider 是 sandbox_providers 段里的一条**档位登记**:一个执行后端 自称提供哪一档隔离。
为什么档位要登记在案,而不是只写在代码里 ¶
后端自报的档位若只存在于实现中,它有三重失效:review 时看不见、改了没人会响、 标高了没人管。而档位是宿主做风险判断的依据——宿主据它决定「这次执行能不能 碰不可信输入」。于是档位声明需要一个**签字处**,policy 这一段就是那个签字处。
level 填常量名而不是档位值 ¶
填 `LevelProcess` 而不是 `process`:门禁**不能 import 被判的包**(它必须是最 底层的,否则「用它判别人」就变成「它自己也在被判定」,门禁的红绿会依赖它所判 的代码),所以它拿不到档位名表,只能比对代码里可静态识别的那一面——常量名。 policy 侧填同一份常量名,两侧就共用一份真相。
type SandboxProviders ¶
type SandboxProviders struct {
Enabled *bool `yaml:"enabled"`
Severity string `yaml:"severity"`
Desc string `yaml:"desc"`
IgnoreDirs []string `yaml:"ignore_dirs"`
Providers []SandboxProvider `yaml:"providers"`
}
SandboxProviders 是 sandbox_providers 段的配置:开关、级别、受忽略目录与登记清单。
形态与 dependency_direction 段同构(配置 + 清单),因为两者都是「一段跨包的 规则 + 一张逐条声明的表」。段缺省即启用——**未登记的后端会被报出来**, 而不是「没登记就当没这回事」。
type Threshold ¶
type Threshold struct {
MaxFileLines int `yaml:"max_file_lines"`
MaxMethodsPerType int `yaml:"max_methods_per_type"`
// ForwardingShellMaxChars 是「转发壳」的方法体长度上限(字符)。nil = 用缺省值。
//
// 转发壳 = 方法体只有一条转发语句、且剥掉注释与空白后长度不超过此值。
// god object 阈值比的是**实质方法数**(方法数 − 转发壳数),因为方法总数在
// façade 上等于**公共 API 表面规模**,不是复杂度集中度(issue #13)。
//
// 显式写 0 = 关闭该区分(所有方法都算实质方法),判定逐字退回旧行为。
ForwardingShellMaxChars *int `yaml:"forwarding_shell_max_chars"`
ScanRoots []string `yaml:"scan_roots"`
Enforce bool `yaml:"enforce"` // true=超限即非零退出;false=仅报告
}
Threshold 是结构债阈值:超长文件与 god object 的机械判据。