填写仓库约定
输入项目名称和技术栈,再逐行填写实际存在的检查命令。操作边界用于记录需要特别处理的动作,完成标准说明怎样确认本次修改已完成。点击生成后,在输出框核对内容;下载按钮会保存为 AGENTS.md。
示例与维护
例如,项目名称填写 my-project,技术栈填写 TypeScript,检查命令填写 npm test 和 npm run build。生成的文件包含 Stack、Commands、Boundaries、Definition of done 和 Review 小节。将命令替换为仓库真实的脚本,并随项目变化维护规则。
把 npm test 写进约定前,展开它会带上的脚本
前面的合并练习确认了命令确实存在。下一步还要问:运行这个入口时,会不会顺便做了任务没有要求的事?例如维护者把发布接在 posttest 上,AGENTS.md 只写“提交前运行 npm test”,读起来像一次测试,实际入口却连到了发布流程。本节专门补这一处,不修改生成器,也不让检查器执行仓库脚本。
npm 的 Scripts 官方文档列出了 test 的 pretest、test、posttest 顺序,并说明自定义脚本也有对应的前后阶段。脚本非零退出会中断流程。因此下文输出是“假设前序成功时需要审阅的展开顺序”,不是执行日志,更不表示这些步骤都已经发生。
本例输入完全虚构。我们会静态展开 test → posttest → release,指出 npm publish 需要单独审阅,同时保留所有看不透的 Node 叶子脚本。只根据名字叫 test 就放行,或只搜索顶层有没有 deploy,都不能发现完整调用关系。
先固定输入和只读检查器的能力边界
把下面文件保存为 package.fixture.json,不要把它改名为 package.json 后执行 npm test。文件里的 npm publish 只是被检查的字符串,演示不会执行它;private 字段也不是这次检查的安全保证。
完整文件:package.fixture.json
{
"name": "notes-audit-fixture",
"private": true,
"scripts": {
"pretest": "node scripts/check-input.cjs",
"test": "node --test test/summary.test.cjs",
"posttest": "npm run release",
"prerelease": "node scripts/check-release.cjs",
"release": "npm publish",
"postrelease": "node scripts/report.cjs"
}
}
检查器只识别精确的 npm test 与 npm run 名称。它在每次显式调用时依次展开已定义的 pre、主体、post。自动进入 pretest 的正文不会仅因名称前缀再自动进入 prepretest;正文若显式调用另一个 npm run,才开始新的展开。它用当前调用栈发现环,而不用全局“看过就跳过”的集合,因为同一个 check 可以被两处合法调用。
分号、重定向、引号、变量、管道、&&、传参、workspace、别名以及其他可执行程序都作为完整不透明叶子保留。工具不会试图猜 shell 语法。opaque-leaf-review 表示必须阅读对应实现与实际环境,不表示安全;action-review 只是针对少数显眼 npm 操作的提醒,不是一份完整危险命令清单。无论有没有提醒,execution_authorized 永远为 false。
入口限定为显式存在的 scripts 项,不模拟 npm start 等内置回退;只描述单包、正常生命周期启用时的命令关系。解析失败、未知引用、调用环和展开上限都不能转成通过结论。输入应是你已取得并检查过的普通 JSON 文件;本例不是面对任意恶意大文件的解析沙箱。
保存完整展开器:只读 JSON,输出需要审阅的路径
完整文件:audit.py
"""Read-only npm script expansion for a deliberately narrow language."""
import json
import re
import sys
NAME = r"[A-Za-z0-9][A-Za-z0-9:_-]*"
CALL = re.compile(r"npm run (" + NAME + r")\Z")
# These are warning patterns, never a complete side-effect classifier.
ACTION = re.compile(r"npm (?:publish|unpublish|install|ci)(?: |$)")
def audit(package, entry="test", max_steps=100):
scripts = package.get("scripts") if isinstance(package, dict) else None
if not isinstance(scripts, dict) or any(
not isinstance(k, str) or not re.fullmatch(NAME, k)
or not isinstance(v, str) or not v.strip()
for k, v in scripts.items()
):
raise ValueError("invalid-scripts")
if not isinstance(entry, str) or not re.fullmatch(NAME, entry):
raise ValueError("invalid-entry")
if type(max_steps) is not int or not 1 <= max_steps <= 100:
raise ValueError("invalid-limit")
steps, issues = [], []
aborted = False
def issue(code, path):
issues.append({"code": code, "path": list(path)})
def body(name, path, active):
nonlocal aborted
if aborted:
return
path = path + (name,)
if len(steps) >= max_steps:
issue("step-limit", path)
aborted = True
return
command = scripts[name].strip()
steps.append({"path": list(path), "command": command})
match = CALL.fullmatch(command)
if command == "npm test":
invoke("test", path, active)
elif match:
invoke(match.group(1), path, active)
elif ACTION.match(command):
issue("action-review", path)
else:
# Never split shell syntax or claim arbitrary executables are safe.
issue("opaque-leaf-review", path)
def invoke(name, path, active):
if aborted:
return
if name in active:
issue("cycle", path + (name,))
return
if name not in scripts:
issue("missing-script", path + (name,))
return
active = active + (name,)
for event in ("pre" + name, name, "post" + name):
if event in scripts:
body(event, path, active)
invoke(entry, (), ())
return {"steps": steps, "issues": issues,
"execution_authorized": False}
if __name__ == "__main__":
try:
with open(sys.argv[1], encoding="utf-8") as stream:
package = json.load(stream)
result = audit(package, sys.argv[2] if len(sys.argv) > 2 else "test")
except (OSError, ValueError, IndexError, RecursionError) as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False))
raise SystemExit(2)
print(json.dumps(result, ensure_ascii=False, indent=2))
路径中的每一级都能回到 package.json 的一个具体字段。调用边和叶子同时保留,例如 posttest 这一项说明为什么进入 release,posttest → release 则显示实际脚本文本。最多记录 100 个步骤,达到上限会留下 step-limit,后续不再继续展开。这个数只是本教学检查器的工作量限制,不是 npm 的执行限制。
程序退出码 0 只代表成功生成报告;有问题的报告同样可以正常生成。不要把这个退出码当成 CI 的安全放行信号。文件读写或 JSON 输入错误时退出 2。若要接入团队门禁,应先定义人工审核与处理状态的契约,本节没有提供自动授权入口。
用独立预期验证顺序、环和重复调用
保存下面的测试文件后,在同一目录运行 python test_audit.py,再运行 python audit.py package.fixture.json。expected 对象是直接写下的固定预期,没有调用被测函数生成。除了完整报告,还覆盖七组缺项、环、复杂 shell、变量、传参、workspace 与安装入口;重复调用、钩子范围、上限和输入类型另做断言。
三个负控报告分别故意删掉前后钩子、清空待审问题、把授权字段改成 true,均必须与固定预期不相等。除此之外,测试还直接改写本夹具 audit.py 的三个实现位置:跳过前后钩子、不记录未知叶子、错误授予执行权限。每个变异后的函数都实际运行,并被同一份固定预期检出;这三处针对性变异不能代表完整变异覆盖率。检查使用显式异常而非 assert,Python -O 也不会跳过。恢复用例仅在内存副本中删掉 posttest,确认发布分支消失,但两个 Node 叶子仍待审。
完整文件:test_audit.py
"""Fixed contracts remain active under python -O. No package commands run."""
def check(condition, message):
if not condition:
raise AssertionError(message)
import json
from copy import deepcopy
from pathlib import Path
from audit import audit
ROOT = Path(__file__).resolve().parent
package = json.loads((ROOT / 'package.fixture.json').read_text())
expected = {'steps': [{'path': ['pretest'], 'command': 'node scripts/check-input.cjs'}, {'path': ['test'], 'command': 'node --test test/summary.test.cjs'}, {'path': ['posttest'], 'command': 'npm run release'}, {'path': ['posttest', 'prerelease'], 'command': 'node scripts/check-release.cjs'}, {'path': ['posttest', 'release'], 'command': 'npm publish'}, {'path': ['posttest', 'postrelease'], 'command': 'node scripts/report.cjs'}], 'issues': [{'code': 'opaque-leaf-review', 'path': ['pretest']}, {'code': 'opaque-leaf-review', 'path': ['test']}, {'code': 'opaque-leaf-review', 'path': ['posttest', 'prerelease']}, {'code': 'action-review', 'path': ['posttest', 'release']}, {'code': 'opaque-leaf-review', 'path': ['posttest', 'postrelease']}], 'execution_authorized': False}
check(audit(package) == expected, 'contract check at original line 27')
cases = [({'test': 'npm run gone'}, [('missing-script', ['test', 'gone'])]), ({'test': 'npm run again', 'again': 'npm test'}, [('cycle', ['test', 'again', 'test'])]), ({'test': 'npm run check && npm publish'}, [('opaque-leaf-review', ['test'])]), ({'test': 'npm run $NEXT'}, [('opaque-leaf-review', ['test'])]), ({'test': 'npm run check -- --fix'}, [('opaque-leaf-review', ['test'])]), ({'test': 'npm --workspace app test'}, [('opaque-leaf-review', ['test'])]), ({'test': 'npm ci'}, [('action-review', ['test'])])]
for scripts, oracle in cases:
got = audit({'scripts': scripts})
check([(i['code'], i['path']) for i in got['issues']] == oracle, 'contract check at original line 40')
repeat = audit({'scripts': {'pretest': 'npm run check', 'test': 'npm run check', 'check': 'node check.cjs'}})
check([s['path'] for s in repeat['steps']] == [['pretest'], ['pretest', 'check'], ['test'], ['test', 'check']], 'contract check at original line 43')
hooks = audit({'scripts': {'prepretest': 'npm publish', 'pretest': 'node a.cjs', 'test': 'node b.cjs'}})
check([s['path'] for s in hooks['steps']] == [['pretest'], ['test']], 'contract check at original line 47')
limited = audit(package, max_steps=2)
check(limited['issues'][-1] == {'code': 'step-limit', 'path': ['posttest']}, 'contract check at original line 49')
check(len(limited['steps']) == 2, 'contract check at original line 50')
try:
audit({'scripts': {'test': 7}})
raise AssertionError('bad input accepted')
except ValueError as exc:
check(str(exc) == 'invalid-scripts', 'contract check at original line 55')
mutants = [deepcopy(expected) for _ in range(3)]
mutants[0]['steps'] = [expected['steps'][1]]
mutants[1]['issues'] = []
mutants[2]['execution_authorized'] = True
check(all((m != expected for m in mutants)), 'contract check at original line 61')
recovered = deepcopy(package)
del recovered['scripts']['posttest']
check(audit(recovered) == {'steps': expected['steps'][:2], 'issues': expected['issues'][:2], 'execution_authorized': False}, 'contract check at original line 65')
# Actual implementation mutants, compiled only from this owned audit.py.
source = (ROOT / "audit.py").read_text(encoding="utf-8")
changes = [
("hooks", 'for event in ("pre" + name, name, "post" + name):', 'for event in (name,):'),
("opaque", 'issue("opaque-leaf-review", path)', 'pass'),
("authorization", '"execution_authorized": False', '"execution_authorized": True'),
]
killed = []
for label, old, new in changes:
check(source.count(old) == 1, "mutation anchor: " + label)
namespace = {"__name__": "owned_mutant"}
exec(compile(source.replace(old, new), "<owned-mutant-" + label + ">", "exec"), namespace)
check(namespace["audit"](package) != expected, "surviving mutant: " + label)
killed.append(label)
print("PASS fixed report; 7 boundary cases; repeat; hook scope; limit; invalid input; 3 negative reports; recovery; 3 implementation mutants killed: " + ", ".join(killed))
for bad_entry in (None, 7, [], "bad name"):
try:
audit(package, entry=bad_entry)
except ValueError as exc:
check(str(exc) == "invalid-entry", "invalid entry error")
else:
raise AssertionError("bad entry accepted")
print("PASS invalid entry types")
本次本地实际输出如下。测试使用 Python 3.12.14,没有模型调用、联网、依赖安装或真实仓库命令执行。
完整文件:tests-normal.txt
PASS fixed report; 7 boundary cases; repeat; hook scope; limit; invalid input; 3 negative reports; recovery; 3 implementation mutants killed: hooks, opaque, authorization
PASS invalid entry types
同样运行 python -O test_audit.py,输出与上面一致。对于每个变异,替换点必须恰好出现一次,避免实现改动后悄悄没有变异到目标。
读懂报告:发布分支在哪里,未知部分又在哪里
完整文件:actual.json
{
"steps": [
{
"path": [
"pretest"
],
"command": "node scripts/check-input.cjs"
},
{
"path": [
"test"
],
"command": "node --test test/summary.test.cjs"
},
{
"path": [
"posttest"
],
"command": "npm run release"
},
{
"path": [
"posttest",
"prerelease"
],
"command": "node scripts/check-release.cjs"
},
{
"path": [
"posttest",
"release"
],
"command": "npm publish"
},
{
"path": [
"posttest",
"postrelease"
],
"command": "node scripts/report.cjs"
}
],
"issues": [
{
"code": "opaque-leaf-review",
"path": [
"pretest"
]
},
{
"code": "opaque-leaf-review",
"path": [
"test"
]
},
{
"code": "opaque-leaf-review",
"path": [
"posttest",
"prerelease"
]
},
{
"code": "action-review",
"path": [
"posttest",
"release"
]
},
{
"code": "opaque-leaf-review",
"path": [
"posttest",
"postrelease"
]
}
],
"execution_authorized": false
}
第一个需要改变任务决定的位置是 posttest → release。当前任务如果只允许测试,就应停在执行前,报告这条路径和原始脚本文本。不能因为 package.fixture.json 中 private 为 true,就试着运行整串命令验证“是否真的发得出去”。前后还有四个不透明叶子,它们可能读取文件、联网、调用子进程,必须检查真实源码、依赖和运行条件。
修复有不同路线,不能自动替维护者选择。维护者可以批准在单独变更中拆开测试与发布入口,再复核新脚本;也可能提供一个已经验证的纯本地检查入口。直接绕开 npm 去执行 node --test 会丢掉 pretest 中可能必要的准备逻辑,因此不能无说明地把它写成等价替代。
npm 的 npm run 官方文档解释了 ignore-scripts 对显式脚本及其前后阶段的不同影响。这里不把切换该选项当作通用修复:主脚本本身仍可能包含不允许的动作,而且跳过准备阶段可能改变测试含义。真实环境还需要核对 .npmrc、环境变量、包目录、workspace、PATH 与所选 shell。
另跑一个只有打印动作的真实 npm 探针
静态展开报告不能证明实际 npm 行为。下面探针另建临时目录,生成只打印 MARK 标记的 Node 文件:pretest 与 test 分别显式调用 check,check 自己有前后钩子,posttest 打印收尾。另放一个不应自动触发的 prepretest。它不读取、更不运行风险夹具,也不安装依赖、不发布包。
探针仅适用于已有可信 npm、Node 与 /bin/sh 的环境。它清理继承的 npm 配置、Node 启动钩子和 shell 初始化变量,把用户配置、全局配置与 npm 缓存放进同一个临时根目录,关闭更新提示并使用离线选项。命令在临时包目录执行;退出后验证整个目录已删除。这仍不是针对任意不可信运行时的隔离沙箱。
完整文件:probe_npm.py
"""Execute only an owned marker fixture; never package.fixture.json."""
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
def check(condition, message):
if not condition:
raise AssertionError(message)
def main():
npm = shutil.which('npm')
node = shutil.which('node')
check(npm and node and Path('/bin/sh').exists(), 'Requires installed npm, Node, /bin/sh')
with tempfile.TemporaryDirectory(prefix='npm-marker-probe-') as tmp:
root = Path(tmp)
scripts = {
'prepretest': 'node marker.cjs UNEXPECTED',
'pretest': 'npm run check',
'test': 'npm run check',
'posttest': 'node marker.cjs POST',
'precheck': 'node marker.cjs CHECK_PRE',
'check': 'node marker.cjs CHECK',
'postcheck': 'node marker.cjs CHECK_POST',
}
(root / 'package.json').write_text(json.dumps({'private': True, 'scripts': scripts}))
(root / 'marker.cjs').write_text("console.log('MARK:' + process.argv[2]);\n")
# Do not inherit npm options or Node startup hooks; distinct config files.
env = {k: v for k, v in os.environ.items()
if not k.lower().startswith('npm_config_')
and k not in {'NODE_OPTIONS', 'NODE_PATH', 'BASH_ENV', 'ENV'}}
for name in ('user.npmrc', 'global.npmrc'):
(root / name).write_text('')
env.update({
'npm_config_userconfig': str(root / 'user.npmrc'),
'npm_config_globalconfig': str(root / 'global.npmrc'),
'npm_config_cache': str(root / 'cache'),
'npm_config_script_shell': '/bin/sh',
'npm_config_update_notifier': 'false',
'npm_config_offline': 'true',
})
def run(args):
return subprocess.run(args, cwd=root, env=env, capture_output=True,
text=True, timeout=30)
versions = {}
for label, executable in [('npm', npm), ('node', node)]:
result = run([executable, '--version'])
check(result.returncode == 0, label + ' version failed')
versions[label] = result.stdout.strip()
cases = [
('hooks_enabled', False, ['MARK:CHECK_PRE', 'MARK:CHECK', 'MARK:CHECK_POST',
'MARK:CHECK_PRE', 'MARK:CHECK', 'MARK:CHECK_POST', 'MARK:POST']),
('hooks_ignored', True, ['MARK:CHECK']),
]
results = []
for label, ignored, expected in cases:
result = run([npm, '--ignore-scripts=' + str(ignored).lower(), 'test'])
markers = [line for line in result.stdout.splitlines() if line.startswith('MARK:')]
check(result.returncode == 0, label + ': ' + result.stderr)
check(markers == expected, label + ': ' + repr(markers))
results.append({'case': label, 'exit_code': result.returncode, 'markers': markers,
'stdout': result.stdout, 'stderr': result.stderr})
check(not root.exists(), 'temporary fixture cleanup failed')
return {'versions': versions, 'cases': results, 'temporary_directory_removed': True}
if __name__ == '__main__':
print(json.dumps(main(), ensure_ascii=False, indent=2))
运行 python probe_npm.py,实际结果如下;python -O probe_npm.py 也得到同样的标记与退出码。stdout 保留了 npm 自己打印的完整执行顺序,便于区分调用行和 MARK 标记。
完整文件:probe-output.json
{
"versions": {
"npm": "11.9.0",
"node": "v24.19.0"
},
"cases": [
{
"case": "hooks_enabled",
"exit_code": 0,
"markers": [
"MARK:CHECK_PRE",
"MARK:CHECK",
"MARK:CHECK_POST",
"MARK:CHECK_PRE",
"MARK:CHECK",
"MARK:CHECK_POST",
"MARK:POST"
],
"stdout": "\n> pretest\n> npm run check\n\n\n> precheck\n> node marker.cjs CHECK_PRE\n\nMARK:CHECK_PRE\n\n> check\n> node marker.cjs CHECK\n\nMARK:CHECK\n\n> postcheck\n> node marker.cjs CHECK_POST\n\nMARK:CHECK_POST\n\n> test\n> npm run check\n\n\n> precheck\n> node marker.cjs CHECK_PRE\n\nMARK:CHECK_PRE\n\n> check\n> node marker.cjs CHECK\n\nMARK:CHECK\n\n> postcheck\n> node marker.cjs CHECK_POST\n\nMARK:CHECK_POST\n\n> posttest\n> node marker.cjs POST\n\nMARK:POST\n",
"stderr": ""
},
{
"case": "hooks_ignored",
"exit_code": 0,
"markers": [
"MARK:CHECK"
],
"stdout": "\n> test\n> npm run check\n\n\n> check\n> node marker.cjs CHECK\n\nMARK:CHECK\n",
"stderr": ""
}
],
"temporary_directory_removed": true
}
hooks_enabled 中 check 的三个标记出现两轮,最后是 POST;没有 UNEXPECTED。hooks_ignored 中只有 CHECK,说明显式运行的主体仍执行,但外层与嵌套调用的自动前后钩子都跳过。本机版本见结果的 versions:不要把文档页面当前标出的 v11.21.0 当成这次实测的 npm 11.9.0。
探针只验证这些受控生命周期行为。它没有运行生产仓库、npm publish、安装、联网或模型代理,不证明真实项目没有副作用。配置加载失败、超时或标记不符都会使探针报错;不要把错误输出当成有效顺序。先保存报错,再检查本机版本、配置和可执行文件来源,不要为得到绿色结果去运行风险夹具。
把结果写回 AGENTS.md,而不是写一句“命令安全”
在本虚构案例中,命令区可以先留下以下待审文本。它是给维护者看的建议,不是已经修改过的真实仓库规则,也不授予修改 package.json 或执行发布的权限。
## 检查入口的当前状态 - 暂不执行 npm test:posttest 显式调用 release,release 的内容为 npm publish。 - pretest、test、prerelease、postrelease 引用的 Node 实现尚未审阅。 - 请维护者确认是否拆开测试与发布入口;批准后在单独变更中处理。 - 修订后重新展开入口,阅读所有不透明叶子,并记录实际执行的目录、命令和结果。 - 静态报告只帮助定位脚本关系,不代表运行授权或测试通过。
当维护者完成拆分时,要重新读取最终的 package.json,而不是只看旧报告。恢复测试里移除 posttest 的副本,已经说明“发布分支消失”和“全部检查可执行”是两个判断。前者可以从这份静态报告得到,后者仍取决于源码、实际权限与运行环境。
资料核查日期为 2026-10-14。两份引用均为 npm 官方 v11 文档,本次访问页面标注 v11.21.0;本地 npm 版本另行记录。本节的新增价值是命令展开与审阅证据,原生成器表单、按钮、默认值与已有合并案例保持原样。
常见问题
生成器会读取我的仓库吗?
不会。内容由你填写的字段组合而成,在浏览器内生成;仓库路径、命令和完成标准需要你核对。