agents-gitflow-guard 0.0.17 → 0.0.19

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/README.md CHANGED
@@ -36,16 +36,26 @@ You define your own branches —
36
36
 
37
37
  ## Quick Start — 30 seconds to a guarded repo
38
38
 
39
- **Step 1 — install**, then restart DSH (plugins load at process startup):
39
+ **Step 1 — install.** All six clients consume the same npm package `agents-gitflow-guard` — pick yours:
40
40
 
41
41
  ```bash
42
- # installs the latest release
43
- dsh plugin --profile web add agents-gitflow-guard
44
- # ...or pin an exact known-good version (recommended; also bypasses stale registry caches)
45
- dsh plugin --profile web add agents-gitflow-guard@0.0.17
42
+ # DSH in-process plugin; restart DSH afterwards (plugins load at startup)
43
+ dsh plugin --profile web add agents-gitflow-guard@0.0.19
46
44
  ```
47
45
 
48
- > **Version gotcha**: a bare `add` resolves whatever `latest` is at install time — on machines behind a stale npm/pnpm registry cache or mirror it may install an old version. If the installed version looks wrong, pin it explicitly. The peer-dependency *warning* pnpm may print is expected: DSH supplies `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools` through its shared profile module fallback at startup (the plugin works normally).
46
+ ```bash
47
+ # Claude Code · Codex · OpenCode · Antigravity — standalone hooks, no DSH needed
48
+ npm i -g agents-gitflow-guard@0.0.19
49
+ ```
50
+
51
+ ```bash
52
+ # Pi — in-process extension
53
+ npm i -D agents-gitflow-guard@0.0.19
54
+ ```
55
+
56
+ > **Version gotcha**: a bare `add` or unpinned `npm i` resolves whatever `latest` is at install time — on machines behind a stale npm/pnpm registry cache or mirror it may install an old version. If the installed version looks wrong, pin it explicitly. (DSH users: the pnpm peer-dependency *warning* is expected — DSH supplies `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools` through its shared profile module fallback at startup; the plugin works normally.)
57
+ >
58
+ > The hook clients (Claude Code · Codex · OpenCode · Antigravity) and Pi need one wiring step after install — walk the per-agent table in [Installation in detail](#installation-in-detail).
49
59
 
50
60
  **Step 2 — configure**, create `gitflow-guard.config.json` in your **project root**:
51
61
 
@@ -294,6 +304,12 @@ archive (optional; you archive after release)
294
304
  | PR/MR into archive | ✅ create allowed; 🚫 merge blocked (you merge in UI) |
295
305
  | local `git merge feature/x` while on integration / preview | 🚫 block (PR/MR required); `update: flexible` allows |
296
306
  | chained commands (`checkout develop && merge feature/x`) | 🚫 blocked — branch switches are simulated per segment, no bypass |
307
+ | force-recreate a protected branch (`git checkout -B/-C <branch>` / `git switch -C`) | 🚫 block (direct ref-update gate) |
308
+ | retarget/delete a protected branch via `git symbolic-ref` | 🚫 block (direct ref-update gate) |
309
+ | `git cherry-pick` / `git revert` while on integration / preview / production / archive | 🚫 block (history rewrite on a protected branch); `-n` / `--no-commit` and `--abort`/`--continue`/`--skip`/`--quit` pass |
310
+ | `sudo`-wrapped git commands (privilege wrapper) | 🚫 wrapper peeled (`sudo -u …` included), underlying command gated |
311
+
312
+ > Two deliberate non-gates, so they don't get "closed" by accident later: `git tag -f` (moving a tag, even pointing at a protected branch) stays exempt — tags are outside the branch-role scope, same as `push --tags`; and a plain `git commit` on a protected branch stays allowed — the guard governs branch roles and merge paths, not content, and the following `git push` is still blocked (remote stays clean).
297
313
 
298
314
  The PR/MR target is resolved via `gh pr view` (GitHub) or `glab mr view` (GitLab). Without a platform CLI, the plugin is conservative.
299
315
 
@@ -307,12 +323,18 @@ The PR/MR target is resolved via `gh pr view` (GitHub) or `glab mr view` (GitLab
307
323
  ---
308
324
  ## Installation in detail
309
325
 
310
- **Prerequisite**: a working [DSH](https://github.com/deepseek-ai/deepseek-harness) installation and **Node.js ≥ 22** on your `PATH` (matches the package `engines` floor and the lowest CI matrix tier standalone hook users bypass npm but still need the runtime).
326
+ **Prerequisite**: **Node.js ≥ 22** on your `PATH` (the package `engines` floor and the lowest CI matrix tier). Every client consumes the **same npm package** `agents-gitflow-guard` only the mounting step differs.
311
327
 
312
- **From the npm registry** the standard path, already covered in [Quick Start](#quick-start--30-seconds-to-a-guarded-repo):
328
+ | Agent | Install command | After that |
329
+ |---|---|---|
330
+ | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.19` | restart DSH — the plugin auto-mounts as a profile layer |
331
+ | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.19` | wire a hook to the `gitflow-guard` binary (below) |
332
+ | Pi | `npm i -D agents-gitflow-guard@0.0.19` | copy `pi/gitflow-guard.ts` into `.pi/extensions/` (below) |
333
+
334
+ **DSH — in-process plugin** (the standard path, already covered in [Quick Start](#quick-start--30-seconds-to-a-guarded-repo)):
313
335
 
314
336
  ```bash
315
- dsh plugin --profile web add agents-gitflow-guard@0.0.17 # pin recommended, see note above
337
+ dsh plugin --profile web add agents-gitflow-guard@0.0.19 # pin recommended, see note above
316
338
  ```
317
339
 
318
340
  Then restart DSH. Upgrades are the same command, followed by another restart.
@@ -326,14 +348,20 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
326
348
 
327
349
  The package declares `dsh.bundle.patch`, so `dsh plugin add` automatically makes it a profile layer — no manual profile editing.
328
350
 
329
- **Standalone agent hooks** — the same guard inside those agents, no DSH required. This repo ships project configs at `.claude/settings.json` (Claude Code), `.codex/hooks.json` (Codex), `.opencode/hook/hooks.yaml` (OpenCode), `.agents/hooks.json` (Antigravity / Google) and `.pi/settings.json` + `.pi/extensions/gitflow-guard.ts` (Pi); any other repo adds its own:
351
+ **Standalone agent hooks** — Claude Code / Codex / OpenCode / Antigravity, no DSH required. Install the CLI once, then reference the `gitflow-guard` binary in each hook config:
352
+
353
+ ```bash
354
+ npm i -g agents-gitflow-guard@0.0.19 # provides the `gitflow-guard` binary
355
+ ```
356
+
357
+ This repo ships project configs at `.claude/settings.json` (Claude Code), `.codex/hooks.json` (Codex), `.opencode/hook/hooks.yaml` (OpenCode) and `.agents/hooks.json` (Antigravity / Google); any other repo adds its own:
330
358
 
331
359
  ```jsonc
332
360
  // Claude Code — .claude/settings.json
333
361
  {
334
362
  "hooks": {
335
363
  "PreToolUse": [
336
- { "matcher": "Bash", "hooks": [{ "type": "command", "command": "/abs/path/gitflow-guard check --platform claude" }] }
364
+ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "gitflow-guard check --platform claude" }] }
337
365
  ]
338
366
  }
339
367
  }
@@ -344,7 +372,7 @@ The package declares `dsh.bundle.patch`, so `dsh plugin add` automatically makes
344
372
  {
345
373
  "hooks": {
346
374
  "PreToolUse": [
347
- { "matcher": "^Bash$", "hooks": [{ "type": "command", "command": "node bin/gitflow-guard.mjs check --platform codex" }] }
375
+ { "matcher": "^Bash$", "hooks": [{ "type": "command", "command": "gitflow-guard check --platform codex" }] }
348
376
  ]
349
377
  }
350
378
  }
@@ -357,7 +385,7 @@ hooks:
357
385
  event: tool.before.bash
358
386
  actions:
359
387
  - bash: |
360
- node "$OPENCODE_PROJECT_DIR/bin/gitflow-guard.mjs" check --platform opencode
388
+ gitflow-guard check --platform opencode
361
389
  ```
362
390
 
363
391
  ```json
@@ -365,7 +393,7 @@ hooks:
365
393
  {
366
394
  "gitflow-guard": {
367
395
  "PreToolUse": [
368
- { "matcher": "run_command", "hooks": [ { "type": "command", "command": "node bin/gitflow-guard.mjs check --platform antigravity" } ] }
396
+ { "matcher": "run_command", "hooks": [ { "type": "command", "command": "gitflow-guard check --platform antigravity" } ] }
369
397
  ]
370
398
  }
371
399
  }
@@ -379,7 +407,7 @@ hooks:
379
407
  Pi loads extensions in-process (no stdin payload, no subprocess hook). Install the shipped entry point into the project and keep the package in devDependencies:
380
408
 
381
409
  ```bash
382
- npm i -D agents-gitflow-guard
410
+ npm i -D agents-gitflow-guard@0.0.19
383
411
  mkdir -p .pi/extensions
384
412
  cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-guard.ts
385
413
  ```
@@ -392,7 +420,7 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
392
420
 
393
421
  - The hook reads the payload on stdin and answers with that platform's protocol: Claude Code / OpenCode → `exit 2` (stderr is the reason + "next step" hint); Codex → JSON `{"hookSpecificOutput":{"permissionDecision":"deny",...}}` on stdout; Antigravity → JSON `{"decision":"deny","reason":...}` on stdout with `exit 0` (Antigravity requires exit 0 and rejects `hookSpecificOutput` / non-allow values). Pi has no stdin protocol: the in-process extension listens to the official `tool_call` event and denies via its return value `{ block: true, reason }` (the CLI subprocess only speaks the internal exit-2 contract).
394
422
  - Only the pre-tool event is needed: the guard blocks *before* the command runs. There is no permit to consume afterwards, so no post-tool hooks are required.
395
- - Use an **absolute path** to the binary hook subprocesses may not inherit your shell `PATH`. `${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs` (Claude Code), `node bin/gitflow-guard.mjs` (Codex, runs from the project working directory), or `$OPENCODE_PROJECT_DIR/bin/gitflow-guard.mjs` (OpenCode) or `node bin/gitflow-guard.mjs` (Antigravity, relative to the workspace `.agents/` dir) also work from a checkout.
423
+ - The examples above call the globally-installed `gitflow-guard` (`npm i -g`). If a hook subprocess can't see it on its `PATH`, point at the full binary path from `npm bin -g` hook subprocesses may not inherit your interactive shell `PATH`. The `bin/gitflow-guard.mjs` paths in this repo's shipped configs only work from a checkout.
396
424
  - Fully opt-in: the hook does nothing unless the repo has `gitflow-guard.config.json` with `enabled: true`.
397
425
 
398
426
  ---
@@ -417,7 +445,7 @@ No. Add only the roles your flow actually has. A solo repo with just `develop` c
417
445
 
418
446
  No, and it is important that you don't treat it as one. It is a workflow guard: it makes an agreed process mechanically enforceable. Text-based command recognition is inherently best-effort — an agent determined to obfuscate a command can slip past the parser.
419
447
 
420
- Within its supported command forms, the role boundary is enforced locally: merging into a protected role branch (integration / preview / production / archive) requires the configured path (PR/MR, or a human merge for production/archive). Standard obfuscation wrappers are classified and blocked — shell wrappers (`sh -c` / `bash -lc`), subshells and backtick/`$()` nesting, `env`/`command`/`nohup`/`xargs` prefixes and `VAR=x` assignments, absolute paths, pipelines and `||` tails, git global options (`-C .`, `--git-dir=…`), wildcard refspecs (`refs/heads/*:refs/heads/*`), `git pull` used as fetch+merge, and the `send-pack`/`update-ref` plumbing. The executable adversarial corpus lives in `tests/accuracy-audit.spec.ts`.
448
+ Within its supported command forms, the role boundary is enforced locally: merging into a protected role branch (integration / preview / production / archive) requires the configured path (PR/MR, or a human merge for production/archive). Standard obfuscation wrappers are classified and blocked — shell wrappers (`sh -c` / `bash -lc`), subshells and backtick/`$()` nesting, `env`/`command`/`nohup`/`xargs`/`sudo` prefixes and `VAR=x` assignments, absolute paths, pipelines and `||` tails, git global options (`-C .`, `--git-dir=…`), wildcard refspecs (`refs/heads/*:refs/heads/*`), `git pull` used as fetch+merge, and the `send-pack`/`update-ref`/`symbolic-ref` plumbing; force-recreating a protected branch (`checkout -B`/`switch -C`) and cherry-pick/revert on a protected branch are blocked by the ref-update / ref-move gates. The executable adversarial corpus lives in `tests/accuracy-audit.spec.ts`.
421
449
 
422
450
  What remains **locally non-defensible**: direct forge-API calls (`gh api repos/…/pulls/N/merge`, `curl`) and commands inside interpreter subprocesses (`node -e "child_process.exec('git push …')"`); arbitrarily deep quoting or encoding stays best-effort by nature. The real, non-bypassable boundary lives in branch-protection rules on your hosting service. Use both — treat this guard as instant feedback and audit trail, not as a security boundary.
423
451
 
package/README.zh.md CHANGED
@@ -35,16 +35,26 @@
35
35
 
36
36
  ## 快速开始——30 秒用上
37
37
 
38
- **第 1 步——安装**,然后重启 DSH(插件在进程启动时加载):
38
+ **第 1 步——安装**。六个客户端吃同一个 npm 包 `agents-gitflow-guard`,按你的 agent 选一条:
39
39
 
40
40
  ```bash
41
- # 安装最新版
42
- dsh plugin --profile web add agents-gitflow-guard
43
- # ...或锁定已知良好版本(推荐; 同时绕开 registry 陈旧缓存)
44
- dsh plugin --profile web add agents-gitflow-guard@0.0.17
41
+ # DSH —— 进程内插件(装完重启 DSH; 插件在进程启动时加载)
42
+ dsh plugin --profile web add agents-gitflow-guard@0.0.19
45
43
  ```
46
44
 
47
- > **版本坑**: 裸 `add` 装的是安装时刻的 `latest`——在 npm/pnpm 注册表缓存或镜像陈旧的机器上可能拿到旧版本。看到版本不对就锁版本。pnpm 打印的 peer 依赖 *警告* 属预期: DSH 启动时经共享模块回退提供 `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools`(插件正常工作)。
45
+ ```bash
46
+ # Claude Code · Codex · OpenCode · Antigravity —— 独立 hook,不需要 DSH
47
+ npm i -g agents-gitflow-guard@0.0.19
48
+ ```
49
+
50
+ ```bash
51
+ # Pi —— 进程内扩展
52
+ npm i -D agents-gitflow-guard@0.0.19
53
+ ```
54
+
55
+ > **版本坑**: 裸 `add` 或不带版本的 `npm i` 装的是安装时刻的 `latest`——在 npm/pnpm 注册表缓存或镜像陈旧的机器上可能拿到旧版本。看到版本不对就锁版本。(DSH 用户: pnpm 打印的 peer 依赖 *警告* 属预期——DSH 启动时经共享模块回退提供 `@deepseek-ai/cordis` / `@deepseek-ai/dsh-tools`,插件正常工作。)
56
+ >
57
+ > hook 客户端(Claude Code · Codex · OpenCode · Antigravity)和 Pi 装完还要各做一步接线——见[安装详解](#安装详解)的逐客户端表。
48
58
 
49
59
  **第 2 步——配置**,在**项目根目录**创建 `gitflow-guard.config.json`:
50
60
 
@@ -290,6 +300,12 @@ archive(可选, 发布后你亲手归档)
290
300
  | 指向 archive 的 PR/MR | ✅ 可创建;🚫 合并被拦(你在 UI 合并) |
291
301
  | 在 integration / preview 上 `git merge feature/x`(本地) | 🚫 拦(须 PR/MR);`update: flexible` 则放行 |
292
302
  | 串联命令(`checkout develop && merge feature/x`) | 🚫 拦——逐段模拟分支切换,无法绕序 |
303
+ | 强制重建受保护分支(`git checkout -B/-C <分支>` / `git switch -C`) | 🚫 拦(直改 ref-update 门禁) |
304
+ | 用 `git symbolic-ref` 重定向/删除受保护分支 | 🚫 拦(直改 ref-update 门禁) |
305
+ | 在 integration / preview / production / archive 上 `git cherry-pick` / `git revert` | 🚫 拦(受保护分支上改写历史);`-n`/`--no-commit` 与 `--abort`/`--continue`/`--skip`/`--quit` 放行 |
306
+ | `sudo` 包装的 git 命令(特权外壳) | 🚫 剥壳(含 `sudo -u …`)后按内层命令判定 |
307
+
308
+ > 两处**刻意不拦**的边界,防止后来者「顺手堵上」造成语义回归:`git tag -f` 移动 tag(即使指向受保护分支)维持豁免——tag 不在分支角色守卫范围,与 `push --tags` 同型;受保护分支上的普通 `git commit` 维持放行——守卫只管分支角色与合入路径、不管内容,后续 `git push` 仍被拦(远端零污染)。
293
309
 
294
310
  PR/MR 目标通过 `gh pr view`(GitHub)或 `glab mr view`(GitLab)解析;没有平台 CLI 时插件走保守路径。
295
311
 
@@ -303,12 +319,18 @@ PR/MR 目标通过 `gh pr view`(GitHub)或 `glab mr view`(GitLab)解析;没有
303
319
  ---
304
320
  ## 安装详解
305
321
 
306
- **前置**:一个可用的 [DSH](https://github.com/deepseek-ai/deepseek-harness) 安装,且 `PATH` 上有 **Node.js ≥ 22**(与包 `engines` 及 CI 矩阵最低档一致——独立 hook 用户不经 npm 安装,同样需要运行时)。
322
+ **前置**:`PATH` 上有 **Node.js ≥ 22**(与包 `engines` 及 CI 矩阵最低档一致)。所有客户端都吃**同一个 npm 包** `agents-gitflow-guard`——只有挂载步骤不同。
307
323
 
308
- **从 npm registry**——标准路径,已在[快速开始](#快速开始30-秒用上)覆盖:
324
+ | 客户端 | 安装命令 | 装完再做什么 |
325
+ |---|---|---|
326
+ | DSH | `dsh plugin --profile web add agents-gitflow-guard@0.0.19` | 重启 DSH——插件自动挂为 profile 层 |
327
+ | Claude Code · Codex · OpenCode · Antigravity | `npm i -g agents-gitflow-guard@0.0.19` | 在各自 hook 配置里指向 `gitflow-guard` 二进制(见下) |
328
+ | Pi | `npm i -D agents-gitflow-guard@0.0.19` | 把 `pi/gitflow-guard.ts` 拷进 `.pi/extensions/`(见下) |
329
+
330
+ **DSH —— 进程内插件**(标准路径,已在[快速开始](#快速开始30-秒用上)覆盖):
309
331
 
310
332
  ```bash
311
- dsh plugin --profile web add agents-gitflow-guard@0.0.17 # 建议锁版本, 见上文提示
333
+ dsh plugin --profile web add agents-gitflow-guard@0.0.19 # 建议锁版本, 见上文提示
312
334
  ```
313
335
 
314
336
  然后重启 DSH。升级用同一命令,再重启一次。
@@ -322,14 +344,20 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
322
344
 
323
345
  包自带 `dsh.bundle.patch` 声明,`dsh plugin add` 自动把它挂为 profile 层,无需手工编辑 profile。
324
346
 
325
- **各 agent 独立 hook**——同一守卫也能在这些 agent 里跑,不依赖 DSH。本仓库已自带 `.claude/settings.json`(Claude Code)、`.codex/hooks.json`(Codex)、`.opencode/hook/hooks.yaml`(OpenCode)、`.agents/hooks.json`(Antigravity / Google)和 `.pi/settings.json` + `.pi/extensions/gitflow-guard.ts`(Pi);其他仓库加自己的 hooks:
347
+ **各 agent 独立 hook**——Claude Code / Codex / OpenCode / Antigravity,不依赖 DSH。全局装一次 CLI,然后引用 `gitflow-guard` 二进制:
348
+
349
+ ```bash
350
+ npm i -g agents-gitflow-guard@0.0.19 # 提供 `gitflow-guard` 二进制
351
+ ```
352
+
353
+ 本仓库已自带 `.claude/settings.json`(Claude Code)、`.codex/hooks.json`(Codex)、`.opencode/hook/hooks.yaml`(OpenCode)和 `.agents/hooks.json`(Antigravity / Google);其他仓库加自己的 hooks:
326
354
 
327
355
  ```jsonc
328
356
  // Claude Code — .claude/settings.json
329
357
  {
330
358
  "hooks": {
331
359
  "PreToolUse": [
332
- { "matcher": "Bash", "hooks": [{ "type": "command", "command": "/abs/path/gitflow-guard check --platform claude" }] }
360
+ { "matcher": "Bash", "hooks": [{ "type": "command", "command": "gitflow-guard check --platform claude" }] }
333
361
  ]
334
362
  }
335
363
  }
@@ -340,7 +368,7 @@ dsh plugin --profile web add file:/path/to/agents-gitflow-guard
340
368
  {
341
369
  "hooks": {
342
370
  "PreToolUse": [
343
- { "matcher": "^Bash$", "hooks": [{ "type": "command", "command": "node bin/gitflow-guard.mjs check --platform codex" }] }
371
+ { "matcher": "^Bash$", "hooks": [{ "type": "command", "command": "gitflow-guard check --platform codex" }] }
344
372
  ]
345
373
  }
346
374
  }
@@ -353,7 +381,7 @@ hooks:
353
381
  event: tool.before.bash
354
382
  actions:
355
383
  - bash: |
356
- node "$OPENCODE_PROJECT_DIR/bin/gitflow-guard.mjs" check --platform opencode
384
+ gitflow-guard check --platform opencode
357
385
  ```
358
386
 
359
387
  ```json
@@ -361,7 +389,7 @@ hooks:
361
389
  {
362
390
  "gitflow-guard": {
363
391
  "PreToolUse": [
364
- { "matcher": "run_command", "hooks": [ { "type": "command", "command": "node bin/gitflow-guard.mjs check --platform antigravity" } ] }
392
+ { "matcher": "run_command", "hooks": [ { "type": "command", "command": "gitflow-guard check --platform antigravity" } ] }
365
393
  ]
366
394
  }
367
395
  }
@@ -375,7 +403,7 @@ hooks:
375
403
  Pi 以进程内扩展装载(没有 stdin payload,也没有子进程 hook)。把随包发布的入口装进项目、包留在 devDependencies:
376
404
 
377
405
  ```bash
378
- npm i -D agents-gitflow-guard
406
+ npm i -D agents-gitflow-guard@0.0.19
379
407
  mkdir -p .pi/extensions
380
408
  cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-guard.ts
381
409
  ```
@@ -388,7 +416,7 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
388
416
 
389
417
  - hook 读 stdin payload,按**各平台协议**作答:Claude Code / OpenCode → `exit 2`(stderr 展示原因 + "下一步"提示);Codex → stdout 输出 JSON `{"hookSpecificOutput":{"permissionDecision":"deny",...}}`;Antigravity → stdout 输出 `{"decision":"deny","reason":...}` 且 **exit 0**(Antigravity 要求 exit 0,拒绝 hookSpecificOutput/非 allow 值)。Pi 没有 stdin 协议:进程内扩展监听官方 `tool_call` 事件,经返回值 `{ block: true, reason }` 拒绝(守卫 CLI 子进程只承载内部 exit-2 契约)。
390
418
  - 只需要**执行前事件**:守卫在命令执行*之前*拦截;没有特许可事后消费,因此无需执行后钩子。
391
- - 用**绝对路径**指向二进制——hook 子进程不一定继承你的 shell PATH。Claude Code `${CLAUDE_PROJECT_DIR}/bin/gitflow-guard.mjs`,Codex `node bin/gitflow-guard.mjs`,OpenCode `$OPENCODE_PROJECT_DIR/bin/gitflow-guard.mjs`、Antigravity `node bin/gitflow-guard.mjs`(相对 workspace `.agents/` 目录)也可以。
419
+ - 上面示例调的是全局安装的 `gitflow-guard`(`npm i -g`)。若 hook 子进程在它的 `PATH` 里找不到,就指向 `npm bin -g` 给出的完整二进制路径——hook 子进程不一定继承你交互 shell 的 PATH。本仓库自带配置里的 `bin/gitflow-guard.mjs` 路径只对 checkout 贡献者有效。
392
420
  - 完全 opt-in:仓库没有 `gitflow-guard.config.json`(或 `enabled` 非 true)时 hook 什么都不做。
393
421
 
394
422
  ---
@@ -413,7 +441,7 @@ cp node_modules/agents-gitflow-guard/pi/gitflow-guard.ts .pi/extensions/gitflow-
413
441
 
414
442
  不是,请注意别把它当安全工具。它是工作流守卫:把既定流程变成可机制执行的东西。基于文本的命令识别天然是尽力而为——铁心混淆命令的 agent 可以绕过解析器。
415
443
 
416
- 在其支持的命令形态内,角色边界在本地强制生效:合入受保护角色分支(integration / preview / production / archive)必须走配置好的路径(PR/MR,或生产/归档的人工合并)。常见混淆包装已纳入分类与拦截——shell 包装(`sh -c` / `bash -lc`)、子 shell 与反引号/`$()` 内嵌、`env`/`command`/`nohup`/`xargs` 前缀与 `VAR=x` 赋值、绝对路径、管道与 `||` 后半段、git 全局选项(`-C .`、`--git-dir=…`)、通配 refspec(`refs/heads/*:refs/heads/*`)、当 fetch+merge 用的 `git pull`,以及 `send-pack`/`update-ref` 等 plumbing。可执行对抗语料见 `tests/accuracy-audit.spec.ts`。
444
+ 在其支持的命令形态内,角色边界在本地强制生效:合入受保护角色分支(integration / preview / production / archive)必须走配置好的路径(PR/MR,或生产/归档的人工合并)。常见混淆包装已纳入分类与拦截——shell 包装(`sh -c` / `bash -lc`)、子 shell 与反引号/`$()` 内嵌、`env`/`command`/`nohup`/`xargs`/`sudo` 前缀与 `VAR=x` 赋值、绝对路径、管道与 `||` 后半段、git 全局选项(`-C .`、`--git-dir=…`)、通配 refspec(`refs/heads/*:refs/heads/*`)、当 fetch+merge 用的 `git pull`,以及 `send-pack`/`update-ref`/`symbolic-ref` 等 plumbing;强制重建受保护分支(`checkout -B`/`switch -C`)与受保护分支上的 cherry-pick/revert 由 ref-update / ref-move 门禁拦截。可执行对抗语料见 `tests/accuracy-audit.spec.ts`。
417
445
 
418
446
  已知**本地不可防**的通道:直连 forge API(`gh api repos/…/pulls/N/merge`、`curl`)与解释器子进程内嵌(`node -e "child_process.exec('git push …')"`);任意深度的引号/编码变换天然只能尽力而为。真正不可绕过的边界在你托管服务的分支保护设置。两边都用——把本守卫当作即时反馈与审计留痕,而不是安全边界。
419
447
 
package/lib/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as resolveLocale, d as gitRunner, f as loadConfig, h as makeT, i as formatDeny, l as currentBranch, o as stateDir, p as roleMatches, r as evaluateCommand, u as findRepoRoot, v as classify } from "./src-BQYC4N6b.mjs";
1
+ import { _ as resolveLocale, d as gitRunner, f as loadConfig, h as makeT, i as formatDeny, l as currentBranch, o as stateDir, p as roleMatches, r as evaluateCommand, u as findRepoRoot, v as classify } from "./src-CL6uB3oo.mjs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  //#region src/platform.ts
package/lib/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as name, c as createPiExtension, g as registerLocale, i as formatDeny, m as MESSAGE_KEYS, n as apply, o as stateDir, r as evaluateCommand, s as userStateRoot, t as appendAudit } from "./src-BQYC4N6b.mjs";
1
+ import { a as name, c as createPiExtension, g as registerLocale, i as formatDeny, m as MESSAGE_KEYS, n as apply, o as stateDir, r as evaluateCommand, s as userStateRoot, t as appendAudit } from "./src-CL6uB3oo.mjs";
2
2
  export { MESSAGE_KEYS, appendAudit, apply, createPiExtension, evaluateCommand, formatDeny, name, registerLocale, stateDir, userStateRoot };
@@ -119,10 +119,12 @@ function classifySegment(segment, ctx) {
119
119
  }
120
120
  /** 分派: 已知命令直接解析; 包装器剥壳后递归(token 只减不增, 必然终止) */
121
121
  function classifyTokens(tokens, ctx) {
122
+ if (tokens.length === 0) return [{ kind: "other" }];
122
123
  const rawCmd = tokens[0];
123
124
  const cmd = rawCmd.includes("/") ? rawCmd.slice(rawCmd.lastIndexOf("/") + 1) : rawCmd;
124
125
  if (SHELLS.has(cmd)) return classifyShellWrapped(tokens, ctx);
125
126
  if (cmd === "env") return classifyTokens(stripEnvArgs(tokens.slice(1)), ctx);
127
+ if (cmd === "sudo") return classifyTokens(stripSudoArgs(tokens.slice(1)), ctx);
126
128
  if (WRAPPERS.has(cmd)) return classifyTokens(stripWrapperArgs(tokens.slice(1)), ctx);
127
129
  if (/^[\w-][\w.-]*=/.test(rawCmd)) return classifyTokens(tokens.slice(1), ctx);
128
130
  if (cmd === "git") return classifyGit(tokens.slice(1), ctx);
@@ -168,6 +170,35 @@ function stripWrapperArgs(args) {
168
170
  while (i < args.length && (args[i].startsWith("-") || /^\d+$/.test(args[i]) || /^[\w-]+=/.test(args[i]))) i++;
169
171
  return args.slice(i);
170
172
  }
173
+ /**
174
+ * sudo 参数剥离: 旗标与 VAR=x 赋值逐个消费; -u/-g/-p(及长旗标)消费下一个参数;
175
+ * `--` 之后即命令本体。仅剥壳不出新语义, 递归分类必然终止。
176
+ */
177
+ function stripSudoArgs(args) {
178
+ const WITH_VALUE = /* @__PURE__ */ new Set([
179
+ "-u",
180
+ "--user",
181
+ "-g",
182
+ "--group",
183
+ "-p",
184
+ "--prompt"
185
+ ]);
186
+ let i = 0;
187
+ while (i < args.length) {
188
+ const a = args[i];
189
+ if (a === "--") return args.slice(i + 1);
190
+ if (WITH_VALUE.has(a)) {
191
+ i += args[i + 1] != null ? 2 : 1;
192
+ continue;
193
+ }
194
+ if (a.startsWith("-") || /^[\w-]+=/.test(a)) {
195
+ i++;
196
+ continue;
197
+ }
198
+ break;
199
+ }
200
+ return args.slice(i);
201
+ }
171
202
  /** 分词: 引号内的空格不拆分 */
172
203
  function tokenize(segment) {
173
204
  return segment.match(/"[^"]*"|'[^']*'|\S+/g)?.map((t) => t.replace(/^['"]|['"]$/g, "")) ?? [];
@@ -181,6 +212,8 @@ function classifyGit(args, ctx) {
181
212
  if (sub === "checkout" || sub === "switch") return parseCheckout(rest);
182
213
  if (sub === "send-pack") return parseSendPack(rest);
183
214
  if (sub === "update-ref") return parseUpdateRef(rest);
215
+ if (sub === "symbolic-ref") return parseSymbolicRef(rest);
216
+ if (sub === "cherry-pick" || sub === "revert") return parseCherryPickLike(rest);
184
217
  if (sub === "reset" || sub === "filter-branch") return [{ kind: "ref-move" }];
185
218
  if (sub === "rebase") return parseRebase(rest);
186
219
  if (sub === "commit") return parseCommit(rest);
@@ -203,6 +236,21 @@ function parseCommit(args) {
203
236
  if (args.some((a) => a === "--amend")) return [{ kind: "ref-move" }];
204
237
  return [{ kind: "other" }];
205
238
  }
239
+ /**
240
+ * cherry-pick/revert 会在当前分支上新提交 → 改写当前 tip, 收编为 ref-move
241
+ * (受保护分支上拒绝, 与 reset/rebase 同型);
242
+ * -n/--no-commit 只改工作树与索引(不移动 tip)与恢复类旗标(abort/continue 等)放行。
243
+ */
244
+ function parseCherryPickLike(args) {
245
+ const RESUME = /* @__PURE__ */ new Set([
246
+ "--abort",
247
+ "--continue",
248
+ "--skip",
249
+ "--quit"
250
+ ]);
251
+ if (args.some((a) => a === "-n" || a === "--no-commit" || RESUME.has(a))) return [{ kind: "other" }];
252
+ return [{ kind: "ref-move" }];
253
+ }
206
254
  /** 剥离子命令前的全局选项(-C <path> / -c <k=v> / --git-dir 等), 否则 git -C . push 会被判 other */
207
255
  function stripGlobalOptions(args) {
208
256
  const WITH_VALUE = /* @__PURE__ */ new Set([
@@ -238,20 +286,44 @@ function stripGlobalOptions(args) {
238
286
  }
239
287
  return args.slice(i);
240
288
  }
241
- /** 分支切换: 门禁放行, 分支状态由 evaluateCommand 按段模拟 */
289
+ /**
290
+ * 分支切换: 普通切换/-b/-c(switch -c)新建 → checkout(放行, 分支状态由 evaluateCommand 模拟);
291
+ * -B/-C 强制重建会静默移动/重建既有 ref(可波及受保护分支), 目标名单独送 ref-update 门禁,
292
+ * 门禁放行后仍按 checkout 模拟切换(两段任一 deny 即整体拦截, 与 push 歧义双解释同机制)。
293
+ * 短旗标簇(-Bf/-bt 等)扫描 b/B/c/C 视同对应形态。
294
+ */
242
295
  function parseCheckout(args) {
243
296
  const first = args[0];
244
297
  if (first === "--") return [{
245
298
  kind: "checkout",
246
299
  branch: null
247
300
  }];
301
+ const name = args[1];
302
+ const validName = name != null && !name.startsWith("-");
248
303
  if (first === "-b" || first === "-B" || first === "-c" || first === "-C") {
249
- const name = args[1];
304
+ if (!validName) return [{
305
+ kind: "checkout",
306
+ branch: null
307
+ }];
308
+ if (first === "-B" || first === "-C") return forceRecreateOut(name);
250
309
  return [{
251
310
  kind: "checkout",
252
- branch: name && !name.startsWith("-") ? name : null
311
+ branch: name
253
312
  }];
254
313
  }
314
+ if (first != null && first.startsWith("-") && !first.startsWith("--") && first.length > 1) {
315
+ const clusterForce = first.includes("B") || first.includes("C");
316
+ if (clusterForce || first.includes("b") || first.includes("c")) {
317
+ if (!validName) return [{
318
+ kind: "checkout",
319
+ branch: null
320
+ }];
321
+ return clusterForce ? forceRecreateOut(name) : [{
322
+ kind: "checkout",
323
+ branch: name
324
+ }];
325
+ }
326
+ }
255
327
  if (first && !first.startsWith("-")) return [{
256
328
  kind: "checkout",
257
329
  branch: first
@@ -261,6 +333,17 @@ function parseCheckout(args) {
261
333
  branch: null
262
334
  }];
263
335
  }
336
+ /** -B/-C(及含 B/C 的旗标簇)的产出: 目标 ref 送 ref-update, 再按 checkout 模拟切换 */
337
+ function forceRecreateOut(name) {
338
+ return [{
339
+ kind: "ref-update",
340
+ branch: stripRefPrefix(name),
341
+ delete: false
342
+ }, {
343
+ kind: "checkout",
344
+ branch: name
345
+ }];
346
+ }
264
347
  function parsePush(args, ctx) {
265
348
  let force = false;
266
349
  let isDelete = false;
@@ -404,6 +487,36 @@ function parseUpdateRef(args) {
404
487
  }
405
488
  return [{ kind: "other" }];
406
489
  }
490
+ /**
491
+ * git symbolic-ref 直改 symbolic refs(plumbing 绕行面):
492
+ * - 查询形态(symbolic-ref <name> / --short 等单参)不改变任何 ref → other;
493
+ * - 双参重定向(symbolic-ref <name> <ref>)把 name 指向别处, 目标名送 ref-update;
494
+ * - -d/--delete 删除该 ref, 同样送 ref-update。
495
+ */
496
+ function parseSymbolicRef(args) {
497
+ let isDelete = false;
498
+ const nonFlag = [];
499
+ for (let i = 0; i < args.length; i++) {
500
+ const a = args[i];
501
+ if (a === "-d" || a === "--delete") {
502
+ isDelete = true;
503
+ continue;
504
+ }
505
+ if (a === "-m" || a === "--message") {
506
+ i++;
507
+ continue;
508
+ }
509
+ if (a.startsWith("-")) continue;
510
+ nonFlag.push(a);
511
+ }
512
+ if (!isDelete && nonFlag.length < 2) return [{ kind: "other" }];
513
+ if (isDelete && nonFlag.length === 0) return [{ kind: "other" }];
514
+ return [{
515
+ kind: "ref-update",
516
+ branch: stripRefPrefix(nonFlag[0]),
517
+ delete: isDelete
518
+ }];
519
+ }
407
520
  function parseMerge(args) {
408
521
  if (args.some((a) => a === "--abort")) return [{ kind: "other" }];
409
522
  return [{
@@ -526,9 +639,9 @@ const en = {
526
639
  "head.unknown": () => "current branch",
527
640
  "denyDeleteOrForce.why": (v) => `Protected branch "${v.branch}" may not be deleted or force-pushed`,
528
641
  "denyDeleteOrForce.next": () => "Delete/force-push on a feature branch outside the protected branches; protected branches are managed by you.",
529
- "refUpdateProtected.why": (v) => `Protected branch "${v.branch}" forbids direct ref updates (update-ref)`,
642
+ "refUpdateProtected.why": (v) => `Protected branch "${v.branch}" forbids direct ref updates (update-ref / symbolic-ref / branch -m|-f / checkout -B)`,
530
643
  "refUpdateProtected.next": () => "Update protected branches via PR/MR; they are managed by you.",
531
- "refMoveProtected.why": () => "Rewriting history on a protected branch (reset / rebase / commit --amend / filter-branch) is not allowed",
644
+ "refMoveProtected.why": () => "Rewriting history on a protected branch (reset / rebase / commit --amend / filter-branch / cherry-pick / revert) is not allowed",
532
645
  "refMoveProtected.next": () => "Do history rewrites on a feature branch; protected branches advance via PR/MR and are managed by you.",
533
646
  "pushAll.why": () => "--all/--mirror push would include protected branches",
534
647
  "pushAll.next": () => "Push branch by branch with an explicit refspec.",
@@ -603,9 +716,9 @@ const zh = {
603
716
  "head.unknown": () => "当前分支",
604
717
  "denyDeleteOrForce.why": (v) => `受保护分支「${v.branch}」禁止删除或强推`,
605
718
  "denyDeleteOrForce.next": () => "删除/强推请到受保护分支外的 feature 分支上操作; 受保护分支由用户亲手管理",
606
- "refUpdateProtected.why": (v) => `受保护分支「${v.branch}」禁止直接改写 refs(update-ref)`,
719
+ "refUpdateProtected.why": (v) => `受保护分支「${v.branch}」禁止直接改写 refs(update-ref / symbolic-ref / branch -m|-f / checkout -B)`,
607
720
  "refUpdateProtected.next": () => "请通过 PR/MR 更新受保护分支; 受保护分支由用户亲手管理",
608
- "refMoveProtected.why": () => "受保护分支禁止本地改写历史(reset / rebase / commit --amend / filter-branch)",
721
+ "refMoveProtected.why": () => "受保护分支禁止本地改写历史(reset / rebase / commit --amend / filter-branch / cherry-pick / revert)",
609
722
  "refMoveProtected.next": () => "历史改写请在 feature 分支上进行; 受保护分支仅经 PR/MR 推进, 由用户亲手管理",
610
723
  "pushAll.why": () => "--all/--mirror 推送会包含受保护分支",
611
724
  "pushAll.next": () => "请逐分支推送并显式指定 refspec",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-gitflow-guard",
3
- "version": "0.0.17",
3
+ "version": "0.0.19",
4
4
  "description": "A configurable branch-role guard for AI coding agents (DSH / Claude Code / Codex / OpenCode / Antigravity / Pi) — integration/preview/production/archive, each with its own update rules",
5
5
  "license": "MIT",
6
6
  "type": "module",