Files
wuguanganandClaude Opus 5 e3b9153b66 feat: 拦住 AI 把密钥搬出去;README 补 AI 时代的隐私那一节
## 密钥泄漏闸

把活交给 agent 之后,泄密的形态变了。以前是人手滑,现在是**每一步都很合理的
一串操作**:git add . 顺手把 .env 提了、把真实 token 内联进配置文件当"示例"、
为了确认配置 cat 一下密钥(值就进了 transcript,0644、永久、每轮重发给 API)。

**没有一条能靠「这条命令危不危险」判断出来。** 它们都不危险。唯一可靠的信号是
这次调用的内容里有没有出现真实的密钥值。

所以加了一道闸,默认开:参数里出现库里的真实值 → 拒;git add/commit 会把
.env 一类文件或含真实值的文件带进仓库 → 拒。拒绝理由里写明正确做法
(用 $KEY、先加 .gitignore),否则模型只会换个写法再试一次。

**它跑在 hubctl 本地,不依赖 app 在不在跑。** 仓库其它拦截都是「app 没开就放行」
(一个监控工具不该让你的命令跑不起来),这一条反过来 —— 密钥漏出去不可逆。

几条守住的边界,判宽了比判窄了危险:值短于 8 字符不参与匹配(abc/true/test
会命中一切);.example/.template/.sample 结尾放行(模板文件本来就该提交,
误拦一次用户就把整个功能关掉了);只认 EnvRenderer 写出来的格式,不做通用
dotenv 解析;不是 git add/commit 就一个子进程都不起。

拒了之后事件照发给 app(不等应答),进审批日志 —— 一道看不见的闸,
用户第一次被拦时只会觉得 Claude 抽风。开关在密钥页底部,
落成 ~/.vibe-foreman/guard.json,且**只在关掉时才写**:
默认开,「文件不存在 = 开着」,这样没打开过密钥页的人也受保护。

明确写了它挡不住什么:python -c 读、软链、base64 转一道、库外手写的值。
它是一道减少手滑的闸,不是沙箱。

## 截图

主窗口现在也能自动截了:HUB_WINDOW_SECTION 钉住打开哪一页,
配 DemoFixtures.seed 造的假密钥和假账号(刻意编得一眼假 —— 一个像真的
token 混进去,读者会去试)。

两个坑:find-window 按面积取第一个会截到岛(它比主窗口先出现一瞬),
改成按宽度 ≥900 筛;.accessory 的进程从后台 shell 起来抢不到焦点,
而非活跃窗口里的系统控件会被去饱和 —— 截出来开关全是灰的,
正好把要展示的东西说反了,所以截图模式下改用 .regular。

顺带:进密钥页自动选中第一组。有组却什么都不选,右边一大片空白,
那是"还没建组"的样子而不是"有四组等你挑"的样子。

646 个测试全过(原 625)。真 hubctl 二进制端到端验过四种情况:
写入真值被拒、约定的 source 用法放行、git add . 带 .env 被拒、普通命令放行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:46:46 +08:00

250 lines
10 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import CoreGraphics
import Foundation
import ImageIO
// 把全部截图拼成一张「一眼看完」的功能长图。
//
// 用途:发到社群 / 公众号 / 知乎时,一张图讲完这个工具是什么。
// 单张截图各自都很清楚,但分散着发没人会逐张点开。
//
// **只用 docs/screenshots/ 里那八张。** 那批全是 DemoFixtures 合成数据
//(storefront / api-gateway / billing-service 都是假的),逐张核对过。
// 拿真实工作区截的图绝不能进这里 —— 验收页尤其危险,它逐条显示着
// 你的需求原文和项目名。
let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
.deletingLastPathComponent()
let shots = root.appendingPathComponent("docs/screenshots")
let outURL = root.appendingPathComponent("docs/poster.png")
let W: CGFloat = 1680
let margin: CGFloat = 80
let contentW = W - margin * 2
/// 一行:一到两张图 + 各自的说明。
struct Row {
let items: [(file: String, title: String, note: String)]
}
let rows: [Row] = [
Row(items: [("01-hover.png",
"① 鼠标划过刘海",
"每个会话一个小人:敲键盘 = 在干活,举手 = 在等你。\n需要你处理的那个被琥珀色框住。")]),
Row(items: [("02-expanded-sessions.png",
"② 点开看全部",
"目录、分支、未提交数、运行了多久、\n在哪个终端里。等你处理的排第一。"),
("03-expanded-projects.png",
"③ 从岛上直接开项目",
"跑着会话的排前面,点一下切过去;\n没跑的点一下直接起一个。")]),
Row(items: [("05-nudge.png",
"④ 卡住了会一直提醒",
"分得清卡在什么上:断线了 / 在问你 /\n等授权 / 还有待办 / 干完了等验收。"),
("06-answer.png",
"⑤ 不用切过去,在岛上答",
"选项由 AI 从上下文里给。\n点了不会直接发,先让你确认一次。")]),
// 验收页放最后、给满幅 —— 这是整个工具的差异化所在,
// 别的同类产品都在做"看见 agent 在干嘛",只有这一页在回答"它有没有真的做到"。
//
// 它同时也顶掉了原来那张主窗口截图:验收页本来就带着主窗口的侧边栏,
// 一张顶两张,而且旧那张的标题栏还写着改名前的名字。
Row(items: [("09-acceptance.png",
"⑥ 它说做完了?拿真实 diff 复核",
"要点来自你的原话和你批准过的计划,不是 Claude 自己列的 todo。它自报做完之后,Hub 拿真实的 git diff 去对 —— 找不到对应改动的会被单独列进「疑似未做」。")]),
// 密钥那两页并排:它们是一对反义词,放一起才说得清 ——
// 左边故意把值写成明文给 AI 用,右边绝不给任何自动化看到。
Row(items: [("10-secrets.png",
"⑦ 平台密钥配一次,给 AI 一个路径",
"按平台建组、勾给项目,物化成 0600 的 .env。\n复制给 AI 的是「source 它,别 Read 它」。"),
("11-credentials.png",
"⑧ 账号密码只给你自己看",
"本地 / 测试 / 线上分开记,加密落盘。\n看一眼和复制都要过 Touch ID。")]),
]
// ── 度量:先算出总高度,再一次性画。
let headerH: CGFloat = 340
let titleH: CGFloat = 46
let noteH: CGFloat = 76
let gap: CGFloat = 30
let rowGap: CGFloat = 78
let footerH: CGFloat = 150
func load(_ name: String) -> NSImage? {
NSImage(contentsOf: shots.appendingPathComponent(name))
}
/// 真实像素尺寸。
///
/// **不能用 `NSImage.size`** —— 它返回的是「点」。这批截图是 Retina 抓的
/// (2x),1880px 的主窗口图它报 940,于是 `min(slot, size.width)` 把大图
/// 按小图处理,排出来只占半幅。第一版就是这么糊的。
func pixelSize(_ image: NSImage) -> CGSize {
for rep in image.representations {
if rep.pixelsWide > 0 {
return CGSize(width: rep.pixelsWide, height: rep.pixelsHigh)
}
}
return image.size
}
/// 一行里每张图能占多宽(两张时对半分,减掉中缝)。
func slotWidth(_ count: Int) -> CGFloat {
count == 1 ? contentW : (contentW - gap) / 2
}
func rowHeight(_ row: Row) -> CGFloat {
let slot = slotWidth(row.items.count)
let tallest = row.items.compactMap { item -> CGFloat? in
guard let image = load(item.file) else { return nil }
let size = pixelSize(image)
// 不放大:这批图是 2x 抓的,按原像素摆最清楚,拉伸只会糊。
let width = min(slot, size.width)
return size.height * (width / size.width)
}.max() ?? 0
return titleH + noteH + tallest
}
let totalH = headerH + rows.reduce(0) { $0 + rowHeight($1) + rowGap } + footerH
guard let ctx = CGContext(
data: nil, width: Int(W), height: Int(totalH),
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { exit(1) }
ctx.setAllowsAntialiasing(true)
let space = CGColorSpaceCreateDeviceRGB()
// ── 底
if let bg = CGGradient(
colorsSpace: space,
colors: [
CGColor(red: 0.043, green: 0.043, blue: 0.059, alpha: 1),
CGColor(red: 0.086, green: 0.082, blue: 0.114, alpha: 1),
] as CFArray,
locations: [0, 1]
) {
ctx.drawLinearGradient(
bg, start: CGPoint(x: 0, y: totalH), end: CGPoint(x: W, y: 0), options: []
)
}
let gfx = NSGraphicsContext(cgContext: ctx, flipped: false)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = gfx
func draw(_ text: String, at point: CGPoint, size: CGFloat, weight: NSFont.Weight,
color: NSColor, tracking: CGFloat = 0, lineHeight: CGFloat = 1.35) {
let style = NSMutableParagraphStyle()
style.lineHeightMultiple = lineHeight
NSAttributedString(string: text, attributes: [
.font: NSFont.systemFont(ofSize: size, weight: weight),
.foregroundColor: color,
.kern: tracking,
.paragraphStyle: style,
]).draw(at: point)
}
// ── 头部
var y = totalH - 120
if let icon = NSImage(contentsOf: root.appendingPathComponent("docs/icon.png")) {
var rect = CGRect(x: margin, y: y - 20, width: 96, height: 96)
if let cg = icon.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
ctx.draw(cg, in: rect)
}
}
draw("Vibe Foreman", at: CGPoint(x: margin + 124, y: y + 38), size: 60, weight: .bold,
color: .white, tracking: -1.2)
draw("给 vibe coding 当工头", at: CGPoint(x: margin + 126, y: y - 6), size: 26,
weight: .medium, color: NSColor(calibratedRed: 0.70, green: 0.68, blue: 0.86, alpha: 1))
y -= 108
draw("不只看见 AI agent 在干嘛,还盯着它有没有真的把活干完。",
at: CGPoint(x: margin, y: y), size: 30, weight: .semibold,
color: NSColor(white: 0.88, alpha: 1))
y -= 52
draw("同时跑七八个 Claude Code 会话时,最难受的是:不知道哪个在等你、知道了也切不过去、它停下来那一瞬你正好没在看。",
at: CGPoint(x: margin, y: y), size: 22, weight: .regular,
color: NSColor(white: 0.55, alpha: 1))
y -= 60
// ── 各行
for row in rows {
let slot = slotWidth(row.items.count)
let height = rowHeight(row)
y -= height
for (index, item) in row.items.enumerated() {
let x = margin + CGFloat(index) * (slot + gap)
draw(item.title, at: CGPoint(x: x, y: y + height - titleH + 6), size: 27,
weight: .semibold, color: .white)
draw(item.note, at: CGPoint(x: x, y: y + height - titleH - noteH + 16), size: 19,
weight: .regular, color: NSColor(white: 0.56, alpha: 1))
guard let image = load(item.file) else { continue }
let size = pixelSize(image)
let width = min(slot, size.width)
let imageH = size.height * (width / size.width)
// 比槽位窄的图在槽位里居中 —— 靠左摆会让右边空出一大块,
// 看起来像排版塌了而不像留白。
let inset = (slot - width) / 2
// 图靠行底对齐:一行里两张不等高时,底对齐比顶对齐整齐得多。
var rect = CGRect(x: x + inset, y: y, width: width, height: imageH)
if let cg = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
// 深色截图压在深色底上会糊,给一圈极淡的描边把边界收住。
ctx.saveGState()
ctx.setShadow(offset: .zero, blur: 26,
color: CGColor(gray: 0, alpha: 0.55))
ctx.draw(cg, in: rect)
ctx.restoreGState()
ctx.setStrokeColor(CGColor(gray: 1, alpha: 0.09))
ctx.setLineWidth(1)
ctx.stroke(rect.insetBy(dx: 0.5, dy: 0.5))
}
}
y -= rowGap
}
// ── 尾
draw("完全开源 · MIT · 原生 Swift · 零第三方依赖 · 530 个测试",
at: CGPoint(x: margin, y: 74), size: 22, weight: .medium,
color: NSColor(white: 0.62, alpha: 1))
draw("github.com/gulugulu-cn/vibe-foreman",
at: CGPoint(x: margin, y: 34), size: 24, weight: .semibold,
color: NSColor(calibratedRed: 0.55, green: 0.72, blue: 0.98, alpha: 1))
NSGraphicsContext.restoreGraphicsState()
// 底部那道渐变线,和社交预览图一致。
if let line = CGGradient(
colorsSpace: space,
colors: [
CGColor(red: 0.30, green: 0.78, blue: 0.95, alpha: 1),
CGColor(red: 0.45, green: 0.45, blue: 0.95, alpha: 1),
CGColor(red: 0.85, green: 0.35, blue: 0.75, alpha: 1),
CGColor(red: 0.98, green: 0.55, blue: 0.35, alpha: 1),
] as CFArray,
locations: [0, 0.35, 0.7, 1]
) {
ctx.saveGState()
ctx.clip(to: CGRect(x: 0, y: 0, width: W, height: 8))
ctx.drawLinearGradient(
line, start: CGPoint(x: 0, y: 0), end: CGPoint(x: W, y: 0), options: []
)
ctx.restoreGState()
}
guard let image = ctx.makeImage(),
let dest = CGImageDestinationCreateWithURL(
outURL as CFURL, "public.png" as CFString, 1, nil
)
else { exit(1) }
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { exit(1) }
print("已生成 \(outURL.path)(\(Int(W))×\(Int(totalH)))")