Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .agents/plugins/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@
"source": {
"source": "url",
"url": "https://github.com/full-stack-plugins/codeguard-plugin.git",
"ref": "v0.15.2"
"ref": "v0.15.3"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_USE"
},
"category": "Developer Tools",
"version": "0.15.2",
"version": "0.15.3",
"description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.",
"icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.2/assets/official-logo.png",
"icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.3/assets/official-logo.png",
"interface": {
"displayName": "代码规范守卫",
"shortDescription": "Trustworthy code checks and Java impact analysis",
"logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.2/assets/official-logo.png"
"logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.3/assets/official-logo.png"
}
}
]
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codeguard",
"version": "0.15.2+codex.20260923",
"version": "0.15.3+codex.20260923",
"description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.",
"author": {
"name": "Full Stack Skills / PartMe.AI",
Expand Down
2 changes: 1 addition & 1 deletion .zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"en": "代码规范守卫",
"zh-CN": "代码规范检查"
},
"version": "0.15.2",
"version": "0.15.3",
"description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.",
"description_i18n": {
"en": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.",
Expand Down
2 changes: 1 addition & 1 deletion kimi.plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codeguard",
"version": "0.15.2",
"version": "0.15.3",
"description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.",
"author": {
"name": "Full Stack Skills / PartMe.AI"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 2026-09-23-fix-heredoc-git-attribution

## Why

fix-release-chain-and-gate-attribution 把非 Shell 脚本正文的 git 归因收紧为调用形态,但其 Non-Goal
留了一个敞口:**外层 Bash heredoc/命令文本数据段的 git 样例仍被切段误伤**。本轮实测该敞口高频咬人
(写作 OpenSpec/测试文档时 heredoc 里的 `git add && git commit && git push` 样例文本 5+ 次整调用被拦,
连写入操作都未执行)。根因:`split_shell_segments` 把 heredoc 正文当 shell 语法切段;且
`_flatten_substitutions` 先于切段抽取 `$(...)`,连引号定界(全字面量)的正文也拦不住。

## What Changes

- heredoc 正文按**归属语义**遮蔽(保留换行的空白)后再切段/展开:
- 数据程序 + 引号定界符:正文全字面量 → 全遮蔽(杀误报);
- 数据程序 + 无引号:正文会做命令替换展开,`$(...)`/反引号跨度保留扫描(规格既有实测向量);
- bash/sh 等 Shell 解释器:正文是内层 shell 代码 → 不遮蔽、保留既有切段建模;
- python/node:正文非 shell 语法 → 全遮蔽(杀误报)。
- 遮蔽前移至 `_flatten_substitutions` 入口,保证引号定界正文的 `$(...)` 不被提前抽取。

## Capabilities

### New Capabilities

无。

### Modified Capabilities

无(heredoc 归属语义以 ADDED requirement 并入 hook-protocol,不重改同一条 requirement)。

## Impact

- `scripts/codeguard/git_syntax.py`(mask_heredoc_bodies + 两处入口接线)。
- 行为变更:heredoc 数据段的 git 字面量样例不再触发门禁;无引号数据正文的命令替换向量、
Shell 解释器正文建模、直接命令判定全部不变(五态语义回归锚定)。
- 已声明边界:python/node **stdin heredoc** 内的 subprocess 调用形态不在归因范围
(与动态拼接同类;脚本文件路径上的调用形态归因不受影响)。
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# hook-protocol(增量):heredoc 正文按归属语义归因

## ADDED Requirements

### Requirement: Heredoc bodies SHALL be attributed by owner semantics

命令文本中 heredoc 正文的 git 归因 MUST 按归属语义区分:数据程序 + 引号定界符的正文是全字面量
(文档样例/模板字符串),MUST NOT 视为 shell 语法切段出 git 副作用;数据程序 + 无引号的正文会做
命令替换展开,`$(...)` 与反引号跨度的内层文本 MUST 保留扫描(其会被外层真实执行);Shell 解释器
(bash/sh/zsh)接收的正文是内层 shell 代码,MUST 按既有切段规则建模;python/node 等非 Shell 解释器
接收的正文不是 shell 语法,MUST NOT 按切段归因。遮蔽处理 MUST 先于命令替换展开执行,
否则引号定界正文的字面 `$(...)` 会被误判为可执行替换。

#### Scenario: Sample text in a python heredoc is not guarded

- **WHEN** `python3 - <<'PY'` 的正文含三引号字符串 `'''cd a && git add && git commit && git push'''`(文档样例)
- **THEN** 判定不命中,钩子放行(此前整调用被拦且写入步骤不执行)

#### Scenario: Command substitution in an unquoted data heredoc is guarded

- **WHEN** `cat <<EOF` 的正文含 `$(git push origin b)` 或反引号包裹的 `git commit`
- **THEN** 展开后命中拦截(外层会真实执行,既有实测向量保持)

#### Scenario: Literal substitution text in a quoted data heredoc is not guarded

- **WHEN** `cat <<'EOF'` 的正文含字面 `$(git push origin b)`
- **THEN** 判定不命中(引号定界正文全字面量,不发生替换)

#### Scenario: Shell interpreter heredoc keeps modeled semantics

- **WHEN** `bash <<'SH'` 的正文含 `git commit -m t`
- **THEN** 判定命中且按一层间接建模(行为与既有切段语义一致)
20 changes: 20 additions & 0 deletions openspec/changes/2026-09-23-fix-heredoc-git-attribution/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Tasks: 2026-09-23-fix-heredoc-git-attribution

## 1. 实现

- [x] 1.1 `mask_heredoc_bodies`:按归属语义遮蔽(引号数据全遮蔽/无引号保留替换跨度/Shell 解释器不遮蔽/非 Shell 全遮蔽)
- [x] 1.2 `split_shell_segments` 与 `_flatten_substitutions` 两处入口接线(遮蔽先于展开)

## 2. 回归测试(五态语义)

- [x] 2.1 python heredoc 三引号样例文本不触发
- [x] 2.2 无引号数据 heredoc 的 `$(git …)` 仍触发(既有向量)
- [x] 2.3 引号数据 heredoc 的字面 `$(git …)` 不触发(flatten 前遮蔽回归)
- [x] 2.4 bash heredoc 真实命令仍触发且建模不变
- [x] 2.5 直接命令判定不变
- [x] 2.6 全量回归 + ruff 干净

## 3. 发版

- [x] 3.1 dogfood 直跑 bump 发 v0.15.3
- [x] 3.2 市场仓同步 + PR/CI/合并 + 两仓推送
80 changes: 80 additions & 0 deletions scripts/codeguard/git_syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,91 @@
"--list", "-l")


_HEREDOC_START = re.compile(
r"(?<![<])<<(?!<)-?\s*(?:'([^']+)'|\"([^\"]+)\"|([A-Za-z_][A-Za-z0-9_]*))"
)
_SHELL_INTERPRETER_NAMES = ("bash", "sh", "zsh", "dash", "ksh")
_NON_SHELL_INTERPRETER_NAMES = ("python", "python3", "node")


def _heredoc_owner_program(command: str, start: int) -> str:
"""heredoc 重定向所在段的程序名(回溯到最近的控制符边界)。"""
seg_start = 0
for i in range(start - 1, -1, -1):
if command[i] in (";", "\n"):
seg_start = i + 1
break
if i >= 1 and command[i - 1:i + 1] in ("&&", "||"):
seg_start = i + 1
break
head = command[seg_start:start]
tokens = head.replace("&&", " ").split()
return tokens[0].rsplit("/", 1)[-1].strip("\"'") if tokens else ""


def mask_heredoc_bodies(command: str) -> str:
"""heredoc 正文按语义遮蔽为保留换行的空白(fix-release-chain 遗留敞口)。

三类语义(2026-09-23 实测区分,别混):
- 数据程序 + 引号定界符(`cat <<'EOF'`):正文全字面量 → 全遮蔽——
模板字符串/帮助文本/文档样例的 git 字面量曾被切段误伤成真实副作用;
- 数据程序 + 无引号(`cat <<EOF`):正文会做命令替换展开,
`$(git push)`/反引号**会被外层真实执行**(既有实测静默放行向量)——
遮蔽正文但保留替换跨度,交给 _flatten_substitutions 展开扫描;
- 解释器定界:bash/sh 正文是内层 shell 代码(不遮蔽,保留切段建模);
python/node 正文不是 shell 语法(全遮蔽杀误报;其 subprocess 调用形态
由 dynamic_git_effects 在脚本文件路径上归因,stdin 形态为已声明边界)。
幂等:已遮蔽的正文不再含定界符结构。
"""
out = list(command)
for match in _HEREDOC_START.finditer(command):
delim = match.group(1) or match.group(2) or match.group(3)
owner = _heredoc_owner_program(command, match.start())
if owner in _SHELL_INTERPRETER_NAMES:
continue
body_start = command.find("\n", match.end())
if body_start < 0:
continue
end_match = re.compile(rf"(?m)^[ \t]*{re.escape(delim)}[ \t]*$").search(
command, body_start + 1)
body_end = end_match.start() if end_match else len(command)
keep_subs = (match.group(1) is None and match.group(2) is None
and owner not in _NON_SHELL_INTERPRETER_NAMES)
if keep_subs:
# 无引号数据正文:保留 $(...) 与反引号跨度(其内层文本会被真实执行)
i = body_start + 1
while i < body_end:
if command[i:i + 2] == "$(":
depth, j = 1, i + 2
while j < body_end and depth:
if command[j] == "(":
depth += 1
elif command[j] == ")":
depth -= 1
j += 1
i = j
continue
if command[i] == "`":
j = command.find("`", i + 1, body_end)
i = (j + 1) if j >= 0 else body_end
continue
if out[i] != "\n":
out[i] = " "
i += 1
else:
for i in range(body_start + 1, body_end):
if out[i] != "\n":
out[i] = " "
return "".join(out)


def split_shell_segments(command: str) -> list[tuple[str, str | None]]:
"""只按未引用、未转义的 Shell 控制符切段,保留前置分隔符。

这不是完整 Shell 解释器;嵌套替换由 `_flatten_substitutions` 另行展开。
引号内的 `;`/`&&` 和 `\\;` 是参数数据,不能合成 git config 豁免。
"""
command = mask_heredoc_bodies(command)
segments: list[tuple[str, str | None]] = []
start = 0
before: str | None = None
Expand Down Expand Up @@ -79,6 +158,7 @@ def _flatten_substitutions(command: str) -> str:
能力边界:单引号内的字面量(`'$(git push)'` 不执行)不做引号语义区分,
宁可多拦不漏拦——误拦方向由 lint 门禁自身的 delta 面兜底(不改文件不红)。
"""
command = mask_heredoc_bodies(command)
parts: list[str] = []
queue: list[str] = [command]
seen = 0
Expand Down
25 changes: 25 additions & 0 deletions tests/test_indirect_git_attribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,30 @@ def test_non_shell_real_call_resolves_to_unverified(self) -> None:
resolve_git_operations(f"node {script}")


class HeredocSemanticsTests(unittest.TestCase):
"""heredoc 正文按归属语义归因(2026-09-23 fix-heredoc-git-attribution 五态)。"""

def test_python_heredoc_sample_text_not_guarded(self) -> None:
cmd = "python3 - <<'PY'\ns = '''cd a && git add && git commit && git push%'''\nprint(s)\nPY"
self.assertFalse(is_guarded(cmd))

def test_unquoted_data_heredoc_substitution_guarded(self) -> None:
self.assertTrue(is_guarded("cat <<EOF\n$(git push origin b)\nEOF"))

def test_quoted_data_heredoc_literal_not_guarded(self) -> None:
self.assertFalse(is_guarded("cat <<'EOF'\n$(git push origin b)\nEOF"))

def test_shell_interpreter_heredoc_still_modeled(self) -> None:
repo = Path(tempfile.mkdtemp())
subprocess.run(["git", "init", "-q"], cwd=repo, capture_output=True, check=False)
cmd = "bash <<'SH'\ngit commit -m t\nSH"
self.assertTrue(is_guarded(cmd))
operations = resolve_git_operations(cmd, cwd=repo)
self.assertTrue(operations and operations[0].mode == "commit")

def test_direct_command_unchanged(self) -> None:
self.assertTrue(is_guarded("git commit -m t"))


if __name__ == "__main__":
unittest.main()
Loading