Compare commits

...

7 Commits

39 changed files with 2092 additions and 119 deletions
+2 -2
View File
@@ -93,8 +93,8 @@ jobs:
- name: Prove real Claude SDK Bash sandbox boundary
run: |
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /var/lib/cph-test
CPH_SANDBOX_TEST_ROOT=/var/lib/cph-test \
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
CPH_SANDBOX_TEST_ROOT=/w/t \
/usr/bin/setpriv --no-new-privs \
npx vitest run test/integration/agent-sandbox-linux.test.ts
+3
View File
@@ -28,6 +28,9 @@
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
控制面与 Docker adapter 后置(见 ADR-0025)。
- Agent skill 只来自 Hub release 内审核过的显式 allowlist,以 release-owned 只读 local
plugin 加载;`settingSources: []` 继续禁用项目/用户配置加载。不得把任意 workspace
`.claude` 配置或未审核 skill 变成运行时能力(见 ADR-0018)。
## 纪律
@@ -93,22 +93,26 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed.
- `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the
SDK's `dangerouslyDisableSandbox` input.
SDK's `dangerouslyDisableSandbox` input. Claude Code 2.1.202 does not enforce
that option reliably, so a host-side `PreToolUse` hook also denies every
Bash request whose input explicitly sets `dangerouslyDisableSandbox: true`
before a process can start.
- `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical
current workspace and a small named system-runtime set — normal reads stay
in the run's workspace while `/bin`, shared libraries, CA certificates,
fonts and the configured `cph` executable remain available as the external
tool exception described above.
- `sandbox.filesystem.allowWrite: [workspaceDir]` confines every write to the
canonical ADR-0007 workspace. Config/cache/home stay beneath
`.cph/agent-runtime/`. `TMPDIR`, `TMP`, and `TEMP` use the absolute
workspace-local `.cph/t/` path so tools remain anchored after `cd`;
`CLAUDE_CODE_TMPDIR` uses the short relative `.cph/t` prefix, resolved from
the canonical workspace cwd, because the SDK's socat bridge otherwise falls
back to a host temp directory when its Unix-socket prefix is too long.
Root is denied for writes and only the canonical workspace is re-opened, so
`/tmp`, `/var/tmp`, sibling projects and every other host path are rejected.
There is no writable scratch exception outside the project directory.
- `sandbox.filesystem.allowWrite: [workspaceDir]` confines persistent host
effects to the canonical ADR-0007 workspace. Config/cache/home stay beneath
`.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all
point at the workspace-local `.cph/t`. The workspace allocator uses stable
compact Organization/Project path segments, deployment requires a short
workspace root, and the canonical temp prefix fails fast above 56 bytes so
the SDK can append randomized `socat` bridge socket names without exceeding
Linux `sockaddr_un.sun_path`. Bubblewrap shadows non-allowlisted host trees
with disposable tmpfs mounts: a shell write there may succeed inside that
private namespace, but it cannot mutate the corresponding host path. The
Linux proof checks host state after the sandbox exits.
- The SDK subprocess environment replaces rather than spreads `process.env`.
Only provider protocol variables and non-secret runtime variables cross the
boundary; database, Feishu and Hub session credentials never enter it.
@@ -118,6 +122,15 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
- `settingSources: []` and strict MCP configuration prevent an untrusted
workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths.
- Platform-curated Agent skills are immutable Hub release assets, loaded as a
programmatic local plugin from a release-owned path. The sandbox exposes that
path read-only, and the SDK receives only plugin-qualified allowlist names
through its `skills` option; SDK-bundled skills are disabled. Filesystem
setting sources remain disabled, so a
project cannot register another skill or widen its tools through `.claude`
settings. Requested skill ids are recorded on `run.created`; SDK
initialization/results remain the authoritative evidence that loading
actually succeeded.
- Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the
+1 -1
View File
@@ -23,7 +23,7 @@ Bot Open ID 不需要管理员手工寻找。平台部署人员会使用 App ID/
- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`
- 获取与上传图片或文件资源(`im:resource`
- 添加、删除消息表情回复(`im:message.reactions:write_only`
- 获取用户基本信息(`contact:user.base:readonly`
- 获取用户基本信息(`contact:user.base:readonly``contact:user.basic_profile:readonly`
如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限。
@@ -0,0 +1,5 @@
{
"name": "cph-curated",
"description": "Reviewed curriculum-production skills shipped with the Curriculum Project Hub.",
"version": "0.0.2"
}
@@ -0,0 +1,72 @@
---
name: data-processing-spec
description: 物理竞赛实验「数据处理」的两套作答规范——超严格版与考试版。当用户要出实验数据处理题、或要求题目答案/解析"按考试版写""按超严格版写""按严格规范作答",或问"什么是考试版/严格版""不确定度取几位""不确定度怎么修约""连算代入哪个值""拟合要不要算 B 类"等数据处理口径问题时使用。出题与批改时据此确定唯一口径。
---
# 数据处理作答规范(超严格版 / 考试版)
物理竞赛实验数据处理里,有效数字取位、不确定度修约、连算代入、拟合是否计 B 类等环节
**各家做法不一致**。为避免"同一份数据出现多个都对的答案",本课程把这些争议点各拍板成
两套自洽的口径:
| 版本 | 用途 | 一句话特征 |
|------|------|-----------|
| **超严格版** | 严格训练 | 每一步贴近误差理论最规范做法,接受较繁的计算量 |
| **考试版** | 考试 / 日常训练 | 在规范前提下简化计算,贴近竞赛复赛阅卷习惯 |
## 怎么用这个 skill
1. **先确定版本。** 用户出题或批改时通常会说明"按考试版"还是"按超严格版"。
- 用户明确指定 → 用该版。
- 用户没指定 → **必须先问**用户要哪一版,不要自己默认。两版在四处刻意不同,
选错会给出末位不同的答案。
2. **读对应规范全文,再动手。** 选定版本后,完整阅读对应文件,按其中每一条口径生成
题目答案 / 解析 / 评分点:
- 超严格版 → [strict-spec.md](strict-spec.md)
- 考试版 → [exam-spec.md](exam-spec.md)
3. **全程只认一版。** 一道题(含所有小问)自始至终用同一版口径,不得中途混用。
4. **需要解释"为什么有两版""某争议点各方怎么做"时** → 读 [disputes.md](disputes.md)
(中立罗列各方做法与依据,不拍板)。
## 两版差异一览(仅这四处不同)
下面四项是两版**唯一的区别**;其余约定两版完全一致(见下一节)。出题/批改时重点核对这四项。
| 争议环节 | 超严格版 | 考试版 |
|----------|----------|--------|
| **不确定度取几位有效数字** | 首位为 1/2/3 取 2 位,其余取 1 位(A2) | 一律取 1 位(A1) |
| **不确定度的修约方向** | 只进不舍(偏保守,代表:北大) | 四舍六入五凑偶(代表:中科大、第 42 届复赛) |
| **多小问连算代入哪个值** | 代入前一问**未修约的真实值**,仅终值修约 | 代入前一问**已修约的填空值**,接受逐问舍入 |
| **线性拟合不确定度** | A 类 + B 类合成(需算 `u_Bk = u_By / √Σ(xix̄)²` | 只算 A 类(`u_k = σ_k` |
> 测量值(中心值)的修约:**两版都用四舍六入五凑偶**——这一条不是差异项。
## 两版共同约定(不随版本变化)
- **A 类不确定度**:取平均值的实验标准差 `u_A = √[Σ(xix̄)² / (n(n1))]`**不做 t 因子修正**。
- **B 类不确定度**`u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。合成 `u = √(u_A² + u_B²)`
- **单次测量**:不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度
(取 `u = Δ仪 / √3`)。出处:实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组
实际指导书为准。
- **有效数字总原则**:测量值位数必须与不确定度对齐——不确定度精确到哪一位,测量值就写到哪一位。
- **线性拟合 A 类**:斜率相对不确定度 `σ_k / k = √[ (1/(n2)) · (1/γ² 1) ]`(γ 为相关系数)。
## 出题/批改自检清单
确定版本后,逐项对照所选规范,确保答案在这些点上口径一致:
- [ ] A 类是否用了"不做 t 修正"的标准差公式
- [ ] B 类是否 `Δ仪/√3`;单次测量是否用仪器误差限
- [ ] 不确定度取了几位(A2 还是 A1)—— **按版本**
- [ ] 不确定度末位修约方向(只进不舍 / 四舍六入五凑偶)—— **按版本**
- [ ] 测量值是否与不确定度对齐、是否用四舍六入五凑偶
- [ ] 多小问连算代入的是真实值还是修约值 —— **按版本**
- [ ] 线性拟合是否计 B 类 —— **按版本**
- [ ] 全卷是否始终只用了这一版口径
## 配套 PDF 源码
`scripts/` 下保留了两版规范与争议点讨论的 Typst 源码,仅作为内容参考。当前 Educraft Agent
运行时不提供独立 `typst` 命令,不要尝试直接编译这些脚本,也不要安装运行时依赖。用户需要
成品 PDF 时,明确说明当前能力边界;若内容要进入课程工程,应按 `lesson-project` 的 cph
0.0.2 结构落地并使用 `cph check/build`
@@ -0,0 +1,51 @@
# 数据处理争议点(中立罗列,不拍板)
本文件解释"为什么会有超严格版 / 考试版两套口径"——每个环节各家做法不一致,本课程把它们各
拍板成两版。这里**只中立罗列各方做法与依据**,不评对错。需要给学生/教练讲清来龙去脉时引用。
## 共同约定(无争议前提)
- A 类不确定度:实验标准差,**不做 t 因子修正**。
- B 类不确定度:`u_B = Δ仪 / √3`(均匀分布)。
- 测量值修约:四舍六入五凑偶。
- 有效数字总原则:测量值位数跟着不确定度走(对齐)。
- 单次测量:以仪器误差限估算,不假设 A 类无穷大(出处:实验指导书"杨氏模量"部分)。
## 争议点 A:不确定度取几位有效数字
- **A1(考试版采用)**:一律 1 位。如 `0.034→0.03``0.12→0.1`
- **A2(超严格版采用)**:首位为 1/2/3 时取 2 位,其余取 1 位。如 `0.123→0.12``0.67→0.7`
- 分歧本质:修约不确定度本身引入的相对误差能容忍多大;A2 为压低该相对误差而保留 2 位。
## 争议点 B:有效数字"反向多取一位"变体
-`u=0.03`,再看测量值对齐位数字:≥3(如 1.87)正常对齐写 `(1.87±0.03)`;以 1/2/3 等更小
数起头(如 1.81)则允许测量值再多取一位、不确定度也反向多取一位 → `(1.812±0.034)`
- 与 A1/A2 不完全等价,是 A 的一个更细变体。本课程两版都未采用此变体(统一走 A1 或 A2),
列出仅供识别学生可能用到的写法。
## 争议点 C:不确定度本身如何修约
- **只进不舍(超严格版采用)**:末位一律进位,报告值偏保守。代表:北京大学。
- **四舍六入五凑偶(考试版采用)**:与测量值同一规则。代表:中国科学技术大学、第 42 届复赛。
- 提示:第 42 届全国中学生物理竞赛复赛对不确定度采用四舍六入五凑偶。
## 争议点 D:多小问连算代入哪个值
- **代入未修约真实值(超严格版采用)**:用完整精度中间量,仅终值修约;避免舍入误差传播,
误差理论上更规范。
- **代入已修约填空值(考试版采用)**:用前一问写出来的修约值;便于逐问复算、阅卷可追溯。
- 两者数值通常只差最后一两位,边界情形可能影响终值末位。
## 争议点 E:线性拟合是否计入 B 类
- A 类无争议:`σ_k/k = √[ (1/(n2))·(1/γ²−1) ]`
- **只算 A 类(考试版采用)**:直接 `u_k=σ_k`;相当多题目/教材实际只算 A 类,且常不说明理由。
- **A 类 + B 类合成(超严格版采用)**:把斜率写成 `k=Σci·yi``ci=(xix̄)/Σ(xjx̄)²`
`u_Bk = u_By / √(Σ(xix̄)²)`,再 `u_k=√(σ_k²+u_Bk²)`
- 为何常省略 B 类:点多、Σ(xi−x̄)² 大时 u_Bk 往往远小于 σ_k 被淹没——但这只是近似经验,非普遍成立。
## 速查对照
| 编号 | 争议内容 | 超严格版 | 考试版 |
|------|----------|----------|--------|
| A | 不确定度取几位 | 首位 1/2/3 取 2 位(A2 | 一律 1 位(A1 |
| C | 不确定度修约方向 | 只进不舍 | 四舍六入五凑偶 |
| D | 连算代入值 | 未修约真实值 | 已修约填空值 |
| E | 拟合是否计 B 类 | A 类 + B 类合成 | 只算 A 类 |
> B 项(反向多取一位变体)两版均不采用,故不在版本差异表内。
@@ -0,0 +1,73 @@
# 数据处理规范 · 考试版
> 用于**考试与日常训练**。在保证规范性的前提下**简化计算**(不确定度一律 1 位、拟合只算 A 类、
> 逐问代入修约值),贴近竞赛复赛阅卷习惯。评分以本规范为唯一口径。与超严格版在四处刻意不同
> (有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)——同一份数据两版可能给出
> 末位不同的答案,**全程只认本版,不可混用**。
## 共同约定(两版一致)
### A 类不确定度
多次测量,取平均值的实验标准差:
```
u_A = √[ Σ(xi x̄)² / (n(n1)) ]
```
- **不做 t 因子修正**,直接以上式为 u_A。
### B 类不确定度
- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。
- 合成:`u = √(u_A² + u_B²)`
### 单次测量
- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`
- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。
## 有效数字与修约(本版选定口径)
### 有效数字总原则
- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。
### 不确定度取几位有效数字 —— 统一 1 位
- **不确定度一律保留 1 位有效数字**(无论首位是几)。测量值随之对齐到该位。
- 示例:`u=0.123 → 0.1`,测量值 `1.8127 → 1.8`,记为 `(1.8 ± 0.1)``u=0.067 → 0.07`,对齐到该位。
### 测量值的修约 —— 四舍六入五凑偶
- 测量值采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。
### 不确定度的修约 —— 四舍六入五凑偶
- 不确定度也采用"四舍六入五凑偶",与测量值同一规则。
- 提示:第 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近
近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。
## 多小问连算 —— 代入上一问修约后的结果
- 后一问用到前一问结果时,**代入前一问已修约、写进答题处的那个值**进行计算。
即接受每问修约带来的舍入误差,换取逐问可复算、便于阅卷。
- 示例:杨氏模量第 1 问报告 `d = 1.8 mm`;第 2 问算 E 时**直接代入 1.8 mm**(而非未修约的 1.8127…)。
## 线性拟合 —— 只算 A 类
`y = k x + b`
- **斜率只计 A 类不确定度,不计 B 类。** 斜率相对不确定度由相关系数 γ 给出:
```
σ_k / k = √[ (1/(n2)) · (1/γ² 1) ]
```
-`u_k = σ_k`,直接作为斜率不确定度上报。
- 说明:数据点多、Σ(xi−x̄)² 较大时拟合的 B 类分量通常远小于 A 类而可忽略,本版据此**只算 A 类**
以简化计算;如需完整合成请改用超严格版。
## 速查(考试版口径)
| 项目 | 本版做法 |
|------|----------|
| A 类不确定度 | 实验标准差,不做 t 修正 |
| B 类不确定度 | Δ仪 / √3 |
| 单次测量 | 以仪器误差限估算 |
| 有效数字 | 不确定度一律 1 位 |
| 测量值修约 | 四舍六入五凑偶 |
| 不确定度修约 | 四舍六入五凑偶 |
| 连算代入 | 代入上一问修约后的结果 |
| 线性拟合 | 只算 A 类 |
@@ -0,0 +1,83 @@
// 共享样式与语义框:两份规范(超严格版 / 考试版)共用
#let rule-color = rgb("#0b4f6c")
#let note-color = rgb("#6a4c00")
#let warn-color = rgb("#b3261e")
// 规范条目框(蓝色):本规范选定的做法
#let rule(body) = block(
width: 100%,
inset: 10pt,
radius: 4pt,
fill: rgb("#eaf2f6"),
stroke: (left: 3pt + rule-color),
body,
)
// 批注 / 出处框(黄色)
#let sidenote(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#fbf6e8"),
stroke: (left: 3pt + note-color),
text(size: 9.5pt, body),
)
// 提醒框(红色)
#let warn(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#fdeeec"),
stroke: (left: 3pt + warn-color),
text(size: 9.5pt, body),
)
// 例子框(灰色)
#let example(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#f3f3f3"),
stroke: (left: 3pt + rgb("#999")),
text(size: 9.5pt, body),
)
// 全局配置 + 封面
#let conf(title: "", subtitle: "", badge: "", badge-color: rgb("#0b4f6c"), doc) = {
set document(title: title, author: "竞赛实验教研组")
set page(
paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm),
numbering: "1 / 1",
number-align: center,
)
set text(font: ("Noto Serif CJK SC",), size: 10.5pt, lang: "zh", region: "cn")
set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true))
show heading: set text(font: ("Noto Sans CJK SC",))
show heading: set block(above: 1.2em, below: 0.7em)
set heading(numbering: "1.1")
show math.equation: set text(font: "New Computer Modern Math")
// 封面
align(center)[
#v(3cm)
#box(
inset: (x: 12pt, y: 6pt),
radius: 6pt,
fill: badge-color,
text(font: ("Noto Sans CJK SC",), size: 13pt, weight: "bold", fill: white, badge),
)
#v(0.9cm)
#text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold", title)
#v(0.5cm)
#text(size: 13pt, fill: rgb("#555"), subtitle)
#v(1.4cm)
#text(size: 11pt)[竞赛实验数据处理 · 评分口径规范]
#v(0.3cm)
#text(size: 10pt, fill: rgb("#777"))[供教练出题与学生研读使用]
]
pagebreak()
doc
}
@@ -0,0 +1,311 @@
// 物理竞赛中数据处理的争议点讨论
// 定位:争议点讨论为主,只中立罗列各方做法,不给本课程拍板结论。
#set document(title: "物理竞赛中数据处理的争议点讨论", author: "竞赛实验教研组")
// ---------- 字体与页面 ----------
#set page(
paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm),
numbering: "1 / 1",
number-align: center,
)
#set text(
font: ("Noto Serif CJK SC",),
size: 10.5pt,
lang: "zh",
region: "cn",
)
#set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true))
#show heading: set text(font: ("Noto Sans CJK SC",))
#show heading: set block(above: 1.2em, below: 0.7em)
#set heading(numbering: "1.1")
// 数学字体不指定 CJK,公式用默认 New Computer Modern Math
#show math.equation: set text(font: "New Computer Modern Math")
// ---------- 一些可复用的语义框 ----------
#let dispute-color = rgb("#b3261e")
#let calm-color = rgb("#1b5e20")
#let note-color = rgb("#6a4c00")
// 无争议约定框
#let agreed(body) = block(
width: 100%,
inset: 10pt,
radius: 4pt,
fill: rgb("#eef6ee"),
stroke: (left: 3pt + calm-color),
body,
)
// 争议点框
#let dispute(body) = block(
width: 100%,
inset: 10pt,
radius: 4pt,
fill: rgb("#fdeeec"),
stroke: (left: 3pt + dispute-color),
body,
)
// 批注 / 出处框
#let sidenote(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#fbf6e8"),
stroke: (left: 3pt + note-color),
text(size: 9.5pt, body),
)
// 各方做法的小标签
#let school(name) = box(
inset: (x: 5pt, y: 1.5pt),
radius: 3pt,
fill: rgb("#e8eef7"),
text(size: 9pt, weight: "bold", name),
)
// ============================================================
// 封面
// ============================================================
#align(center)[
#v(3.2cm)
#text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold")[
物理竞赛中数据处理的\
争议点讨论
]
#v(0.6cm)
#text(size: 13pt, fill: rgb("#555"))[—— 不确定度、有效数字与拟合的多种规范对照 ——]
#v(1.4cm)
#text(size: 11pt)[竞赛实验数据处理 · 教研与教学参考]
#v(0.4cm)
#text(size: 10pt, fill: rgb("#777"))[供教练备课与学生研读使用]
]
#pagebreak()
// ============================================================
// 阅读说明
// ============================================================
= 这份文档怎么读
本文档的目的,是把竞赛实验数据处理中那些"两种甚至多种做法都在流传、却没有统一答案"的地方一次性摆清楚。它*不是*一份判定对错的评分标准,而是一份*争议点对照表*
- 凡是本领域已有共识、几乎不会引起争论的内容,归入 #text(fill: calm-color)[*"约定"*](绿色框),作为后续讨论的共同前提;
- 凡是各家(教材、命题、竞赛习惯)做法不一致的地方,单列为 #text(fill: dispute-color)[*"争议点"*](红色框),并尽量中立地列出每一方的做法与其依据;
- 个别需要交代来源或加以提醒的内容,用#text(fill: note-color)[*批注框*](黄色框)标出。
#sidenote[
*关于"中立"。* 本文档对每个争议点*只罗列、不拍板*。哪一套规则作为本课程或某次测验的评分口径,由教练在使用时另行约定并提前告知学生——这一点务必在出题或考试前说清楚,否则同一份数据会出现多个"都对"的答案。
]
// ============================================================
// 第一部分:共同约定
// ============================================================
= 共同约定(基本无争议)
下面几条在我们的处理体系里是稳定的前提,先固定下来,后面讨论争议时不再反复。
== A 类不确定度
多次测量下,A 类不确定度按样本标准差给出(贝塞尔公式给出的实验标准差,再除以 $sqrt(n)$ 得到平均值的标准不确定度):
$ u_A = s(overline(x)) = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $
#agreed[
*约定 1:A 类不确定度不做 $t$ 因子(学生 $t$ 分布)修正。* 即直接以上式作为 $u_A$,不再乘以与测量次数有关的 $t$ 因子或包含因子。这是本体系的固定口径。
]
== B 类不确定度(多次测量)
B 类不确定度由仪器误差限 $Delta_"仪"$ 给出,按均匀分布折算:
$ u_B = frac(Delta_"仪", sqrt(3)) $
#agreed[
*约定 2B 类不确定度 $= Delta_"仪" \/ sqrt(3)$。* 这里取 $sqrt(3)$ 对应仪器误差在 $plus.minus Delta_"仪"$ 区间内服从均匀分布的假设。
]
合成不确定度按方和根:$u = sqrt(u_A^2 + u_B^2)$
== 测量值的修约方式
#agreed[
*约定 3:测量值(中心值)一律采用"四舍六入五凑偶"修约。* 即逢四舍、逢六入,逢五时看前一位凑成偶数。注意:这一条只针对*测量值*;不确定度本身怎么修约是有争议的(见 @sec:round-u)。
]
#agreed[
*约定 4:测量值的位数必须与不确定度对齐。* 不确定度精确到哪一位,测量值就写到哪一位,不多写也不少写。换言之,*有效数字跟着不确定度走*——这是整个有效数字问题的总原则。争议只在于"不确定度本身取几位"以及"末位怎么修约"。
]
// ============================================================
// 第二部分:单次测量
// ============================================================
= 单次测量的特别约定
多次测量时 A 类、B 类各司其职。但有时只做*单次测量*,此时不能简单地认为"没有重复测量,A 类不确定度就趋于无穷大、结果无法估计"。
#agreed[
*约定 5(单次测量):单次测量时,直接用仪器误差限来估算该次测量的不确定度*,即把 $Delta_"仪" \/ sqrt(3)$(或按所采用规范直接用 $Delta_"仪"$)作为这一次测量结果的不确定度,而*不*假设 A 类不确定度为无穷大。
]
#sidenote[
*出处批注。* 此约定的依据来自*实验指导书中"杨氏模量"实验*的相应章节——该实验对某些只测一次的量(如仪器读数类的单次量)即采用"以仪器误差限估算单次测量误差"的处理。使用本文档时,若所在教学体系的指导书版本不同,请以本组实际采用的指导书"杨氏模量"部分为准核对此条措辞。
]
// ============================================================
// 第三部分:争议点
// ============================================================
= 争议点
以下每一条都没有"唯一正确"的答案。请教练在使用前选定口径并告知学生。
== 争议点 A:不确定度取几位有效数字 <sec:u-digits>
总原则没有争议(约定 4:测量值跟着不确定度对齐)。争议在于*不确定度本身*保留几位有效数字。
#dispute[
*做法 A1:不确定度一律取 1 位有效数字。*
无论首位是几,不确定度都只写 1 位。例如 $u = 0.034 0.03$$u = 0.12 0.1$。对应测量值也只对齐到该位。
*做法 A2:首位为 1、2、3 时取 2 位有效数字,其余取 1 位。*
当不确定度首位较小(1、2、3)时,只留 1 位会带来较大的相对截断,故允许保留 2 位。例如 $u = 0.123 0.12$(首位 1,取 2 位),而 $u = 0.67 0.7$(首位 6,取 1 位)。
]
#sidenote[
A1 A2 的分歧本质,是"修约不确定度本身引入的相对误差能容忍多大"。A2 的"1/2/3 取两位"正是为压低这一相对误差而设。两套都很常见,命题时必须二选一并写明。
]
== 争议点 B:有效数字的"反向多取一位"变体 <sec:reverse-digit>
这是争议点 A 的一个更细的变体,单独列出,因为它对测量值写法的影响最直接。
#dispute[
*做法 B("看测量值末位决定是否多取一位"):*
设不确定度形如 $u = 0.03$。再看测量值在对齐位上的数字:
- 若该位数字 $>= 3$(如测量值 $= 1.87$,末位 7),则*正常对齐*,写成 $(1.87 plus.minus 0.03)$
- 若该位数字以 1、2、3 这类较小数字开头(如测量值 $= 1.81$),则*允许测量值再多取一位*,并*相应地让不确定度也反向多取一位*,写成 $(1.81 plus.minus 0.03) (1.812 plus.minus 0.034)$ 之类。
]
#sidenote[
做法 B 的动机与 A2 一致——都是为了在数值较小时避免过度修约损失精度,只不过 B 是"由测量值末位反推是否多留一位,并让不确定度跟着多留一位"。它与 A1/A2 不完全等价,使用时要明确到底以哪条为准,避免学生在同一题里混用三套规则。
]
== 争议点 C:不确定度本身如何修约 <sec:round-u>
测量值用四舍六入五凑偶已是约定(约定 3)。但*不确定度*的修约方向有分歧。
#dispute[
*做法 C1(只进不舍 / 向上取整):* 不确定度修约时一律*只进不舍*,即末位无论被舍去的部分是多少都进位,使报告的不确定度偏保守(偏大)。
#v(0.3em)
代表口径:#school[北京大学]
*做法 C2(四舍六入五凑偶):* 不确定度与测量值一样,采用四舍六入五凑偶修约。
#v(0.3em)
代表口径:#school[中国科学技术大学] #school[ 42 届物理竞赛复赛]
]
#sidenote[
*特别提示:* 42 届全国中学生物理竞赛复赛对不确定度采用的是*四舍六入五凑偶*(即做法 C2)。若以贴近近年竞赛复赛阅卷习惯为目标,这一点值得在教学时强调;但日常训练里两种都可能遇到,仍以"出题时声明口径"为准。
]
== 争议点 D:多小问连算时,代入哪一个值 <sec:carry-value>
一道大题常有多个小问,前一问的结果会被后一问用到。典型如杨氏模量:第 1 问先求直径 $d$(含不确定度),第 2 问再用 $d$ 求杨氏模量 $E$。问题是:算 $E$ 时代入哪个 $d$
#dispute[
*做法 D1(代入未修约的"真实值"):* 用第 1 问计算过程中得到的*完整精度的 $d$*(小数点后很多位、未做修约)代入后续计算,最后只在终值处统一修约。
#v(0.3em)
依据:修约只应在*最终报告*时进行;中途代入修约值会引入*舍入误差*并逐级传播。从误差理论看这是更规范的做法。
*做法 D2(代入第 1 问已修约的填空值):* 用第 1 *答题卡上已经修约、写进横线里的那个 $d$*(如 $d = 1.81 "mm"$)代入后续计算。
#v(0.3em)
依据:答题与阅卷的可追溯性——后一问的结果应当能由前一问"写出来的答案"复现;某些阅卷口径据此判分。
]
#sidenote[
D1 是误差理论上更干净的做法(避免人为舍入误差累积),D2 则更贴合"按填写值逐问复算"的阅卷便利。两者在数值上通常只差最后一两位,但在边界情形可能影响终值修约后的末位。出题时应明确要求学生采用哪一种,并保持全卷一致。
]
== 争议点 E:线性拟合是否计入 B 类不确定度 <sec:fit>
线性拟合 $y = k x + b$ 中,斜率 $k$ *A 类*不确定度有成熟公式,无争议;争议在于*要不要再算 B 类并合成*
=== A 类(无争议部分)
斜率的 A 类相对不确定度可由相关系数 $gamma$ 表示:
$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $
其中 $gamma$ 为线性相关系数,$n$ 为数据点个数。这是线性拟合 A 类不确定度的常用表达,本身不引起争议。
#agreed[
*无争议:* 线性拟合斜率的 A 类不确定度套用上面的 $sigma_k \/ k$(用 $gamma$ 表示)公式。
]
=== 争议:要不要再加 B 类
#dispute[
*做法 E1(只算 A 类,不计 B 类):* 直接以拟合给出的 $sigma_k$ 作为斜率不确定度,不再考虑各测量点仪器误差带来的 B 类分量。
#v(0.3em)
现状:*相当多的题目与教材实际上只算 A 类*,而且常常*没有把"为什么忽略 B 类"说清楚*——这正是混乱的来源。
*做法 E2(A 类与 B 类合成):* 认为每个测量点都带有 B 类不确定度,应推导出斜率的 B 类分量后与 A 类方和根合成。
#v(0.3em)
现状:原则上更完整,但*少见教材给出现成公式*,需要自行推导(见下)。
]
=== 线性拟合 B 类不确定度的推导(供采用 E2 时参考)
考虑最小二乘斜率的标准表达
$ k = frac(sum_(i) (x_i - overline(x))(y_i - overline(y)), sum_(i) (x_i - overline(x))^2) = frac(sum_i (x_i - overline(x)) y_i, sum_i (x_i - overline(x))^2). $
$k$ 看成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,其中权重
$ c_i = frac(x_i - overline(x), sum_j (x_j - overline(x))^2). $
若每个 $y_i$ 带有相互独立的 B 类不确定度 $u_(B,y)$(由纵轴量的仪器误差限给出,$u_(B,y) = Delta_("仪",y) \/ sqrt(3)$,且各点近似相同),按不确定度传播:
$ u_(B,k) = sqrt(sum_i c_i^2 u_(B,y)^2) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $
*斜率的 B 类不确定度等于纵轴单点 B 类不确定度,除以自变量的"离差平方和的平方根"* $sqrt(sum_i (x_i-overline(x))^2)$
如横轴量 $x$ 的仪器误差也不可忽略,可类似地把它折算到 $y$ 方向(乘以斜率 $k$)后并入 $u_(B,y)$;此处从略。最终斜率的合成不确定度为
$ u_k = sqrt(sigma_k^2 + u_(B,k)^2). $
#sidenote[
*为什么会有 E1 这种"只算 A 类"的现状?* 当数据点较多、且离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,$u_(B,k) = u_(B,y) \/ sqrt(sum_i (x_i-overline(x))^2)$ 往往远小于 A 类的 $sigma_k$,于是 B 类被"淹没"而省略。但这只是*近似成立的经验*,并非普遍正确——所以是否计入 B 类、以及是否声明忽略理由,仍是一个需要出题时明确的争议点。
]
// ============================================================
// 速查表
// ============================================================
#pagebreak()
= 争议点速查表
#table(
columns: (auto, 1fr, 1fr),
inset: 8pt,
align: (left + horizon, left, left),
stroke: 0.5pt + rgb("#cccccc"),
fill: (_, row) => if row == 0 { rgb("#e8eef7") } else { white },
table.header(
[*编号*], [*争议内容*], [*主要分歧 / 代表口径*],
),
[A], [不确定度取几位有效数字], [A1 一律 1 位 / A2 首位为 1·2·3 时取 2 ],
[B], [有效数字"反向多取一位"变体], [测量值末位 ≥3 正常对齐;以 1·2·3 起更小时,测量值与不确定度同时多取一位],
[C], [不确定度本身如何修约], [C1 只进不舍(北大) / C2 四舍六入五凑偶(中科大、第 42 届复赛)],
[D], [多小问连算代入哪个值], [D1 代入未修约真实值(误差理论更规范) / D2 代入第一问已修约的填空值(便于复算阅卷)],
[E], [线性拟合是否计入 B ], [E1 只算 A 类(常见但常不说明理由) / E2 A 类与 B 类合成(需自行推导 $u_(B,k)=u_(B,y)\/sqrt(sum (x_i-overline(x))^2)$],
)
#v(0.6em)
#sidenote[
*使用建议:* 每次出题或测验前,针对表中 AE 各项各选定一种口径,连同"A 类不做 $t$ 修正""$u_B=Delta_"仪"\/sqrt(3)$""单次测量以仪器误差限估算"等约定一并写在卷首说明里。口径一旦公布,全卷保持一致,避免同一份数据出现多个"都对"的答案。
]
@@ -0,0 +1,115 @@
#import "conf.typ": conf, rule, sidenote, warn, example
#show: conf.with(
title: "数据处理规范\n考试版",
subtitle: "—— 贴近竞赛阅卷习惯、计算量适中的实用口径 ——",
badge: "考试版",
badge-color: rgb("#0b4f6c"),
)
= 规范定位
本规范用于*考试与日常训练*场景,在保证规范性的前提下*简化计算*(不确定度一律 1 位、拟合只算 A 类、逐问代入修约值),贴近竞赛复赛的阅卷习惯。学生应严格按本规范作答;评分以本规范为唯一口径。
#warn[
本规范与《超严格版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。
]
= 共同约定(两版一致)
== A 类不确定度
多次测量,A 类不确定度取平均值的实验标准差:
$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $
#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$]
== B 类不确定度
#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。]
合成:$u = sqrt(u_A^2 + u_B^2)$
== 单次测量
#rule[
*单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。
]
#sidenote[
*出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。
]
= 有效数字与修约(本版选定口径)
== 有效数字总原则
#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。]
== 不确定度取几位有效数字 —— 统一 1 位
#rule[
*不确定度一律保留 1 位有效数字*(无论首位是几)。测量值随之对齐到该位。
]
#example[
$u = 0.123 0.1$,测量值 $1.8127 1.8$,记为 $(1.8 plus.minus 0.1)$ $u = 0.067 0.07$,对齐到该位。
]
== 测量值的修约 —— 四舍六入五凑偶
#rule[*测量值采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。]
== 不确定度的修约 —— 四舍六入五凑偶
#rule[
*不确定度也采用"四舍六入五凑偶"修约*,与测量值同一规则。
]
#sidenote[
*提示:* 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。
]
= 多小问连算 —— 代入上一问修约后的结果
#rule[
大题分多小问、后一问要用到前一问结果时,*代入前一问已修约、写进答题处的那个值*进行计算。即*接受每一问修约带来的舍入误差*,换取逐问可复算、便于阅卷。
]
#example[
杨氏模量:第 1 问报告 $d = 1.8 "mm"$。第 2 问算 $E$ *直接代入 $d = 1.8 "mm"$*(而非未修约的 $1.8127...$)。
]
= 线性拟合 —— 只算 A 类
$y = k x + b$
#rule[
*线性拟合斜率只计 A 类不确定度,不计 B 类。* 斜率相对不确定度由相关系数 $gamma$ 给出:
$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)). $
$u_k = sigma_k$,直接作为斜率不确定度上报。
]
#sidenote[
当数据点较多、自变量离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,拟合的 B 类分量通常远小于 A 类而可忽略。本版据此*只算 A 类*以简化计算;如需完整合成请改用《超严格版》。
]
= 速查(考试版口径)
#table(
columns: (auto, 1fr),
inset: 8pt,
align: (left + horizon, left),
stroke: 0.5pt + rgb("#cccccc"),
fill: (_, row) => if row == 0 { rgb("#e3edf2") } else { white },
table.header([*项目*], [*本版做法*]),
[A 类不确定度], [实验标准差,不做 $t$ 修正],
[B 类不确定度], [$Delta_"仪" \/ sqrt(3)$],
[单次测量], [以仪器误差限估算],
[有效数字], [不确定度一律 1 ],
[测量值修约], [四舍六入五凑偶],
[不确定度修约], [四舍六入五凑偶],
[连算代入], [代入上一问修约后的结果],
[线性拟合], [只算 A ],
)
@@ -0,0 +1,130 @@
#import "conf.typ": conf, rule, sidenote, warn, example
#show: conf.with(
title: "数据处理规范\n超严格版",
subtitle: "—— 每一步都按误差理论最规范的方法处理 ——",
badge: "超严格版",
badge-color: rgb("#7a1f1f"),
)
= 规范定位
本规范用于*严格训练*场景,目标是让每一步都贴近误差理论上最规范的做法,*接受较繁的计算量以换取处理的严谨性*。学生应严格按本规范作答;评分以本规范为唯一口径。
#warn[
本规范与《考试版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。
]
= 共同约定(两版一致)
== A 类不确定度
多次测量,A 类不确定度取平均值的实验标准差:
$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $
#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$]
== B 类不确定度
#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。]
合成:$u = sqrt(u_A^2 + u_B^2)$
== 单次测量
#rule[
*单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。
]
#sidenote[
*出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。
]
= 有效数字与修约(本版选定口径)
== 有效数字总原则
#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。]
== 不确定度取几位有效数字 —— 采用 A2
#rule[
*不确定度首位为 1、2、3 时保留 2 位有效数字;首位为 4\~9 时保留 1 位。*
]
#example[
$u = 0.123 0.12$(首位 1,取 2 位); $u = 0.067 0.07$(首位 6,取 1 位); $u = 0.28 0.28$(首位 2,取 2 位)。
]
== 测量值的修约 —— 四舍六入五凑偶
#rule[*测量值一律采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。]
== 不确定度的修约 —— 只进不舍
#rule[
*不确定度修约时一律"只进不舍"*:在保留位之后只要有非零数字(乃至向上保守),末位即进位,使报告的不确定度偏保守(偏大)。
]
#example[
$u = 0.121 0.13$(保留 2 位,末位进 1); $u = 0.341 0.4$(保留 1 位,进位)。
]
#sidenote[
"只进不舍"是较保守的口径(代表:北京大学)。它确保报告的不确定度不会因修约而偏小。
]
= 多小问连算 —— 代入未修约真实值
#rule[
大题分多小问、后一问要用到前一问结果时,*一律代入前一问计算所得的完整精度数值(未修约的"真实值")*,仅在每问*最终报告*时按上面的规则修约。中途不得代入已修约的填空值。
]
#example[
杨氏模量:第 1 问算得 $d = 1.8127... "mm"$(报告时修约为 $1.81 "mm"$)。第 2 问算 $E$ *代入 $d = 1.8127...$*,而非 $1.81$,避免逐级累积舍入误差。
]
= 线性拟合 —— A 类与 B 类合成
$y = k x + b$
== A 类
斜率 A 类相对不确定度由相关系数 $gamma$ 表示:
$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $
== B 类(本版必须计入)
把斜率写成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,权重 $c_i = (x_i - overline(x)) \/ sum_j (x_j - overline(x))^2$。设各点纵轴 B 类不确定度近似相同、为 $u_(B,y) = Delta_("仪",y)\/sqrt(3)$,按传播:
$ u_(B,k) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $
#rule[
*斜率不确定度取 A 类与 B 类的方和根*
$ u_k = sqrt(sigma_k^2 + u_(B,k)^2), wide u_(B,k) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $
]
#sidenote[
若横轴量 $x$ 的仪器误差不可忽略,可乘以斜率 $k$ 折算到 $y$ 方向后并入 $u_(B,y)$。当数据点多、$sum_i (x_i-overline(x))^2$ 大时 $u_(B,k)$ 往往很小,但本版*不因此省略*,一律计入。
]
= 速查(超严格版口径)
#table(
columns: (auto, 1fr),
inset: 8pt,
align: (left + horizon, left),
stroke: 0.5pt + rgb("#cccccc"),
fill: (_, row) => if row == 0 { rgb("#f0e6e6") } else { white },
table.header([*项目*], [*本版做法*]),
[A 类不确定度], [实验标准差,不做 $t$ 修正],
[B 类不确定度], [$Delta_"仪" \/ sqrt(3)$],
[单次测量], [以仪器误差限估算],
[有效数字], [首位 1/2/3 2 位,其余 1 位(A2],
[测量值修约], [四舍六入五凑偶],
[不确定度修约], [只进不舍],
[连算代入], [代入未修约真实值],
[线性拟合], [A + B 类合成],
)
@@ -0,0 +1,85 @@
# 数据处理规范 · 超严格版
> 用于**严格训练**。目标:每一步贴近误差理论上最规范的做法,**接受较繁的计算量以换取严谨性**。
> 评分以本规范为唯一口径。与考试版在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、
> 拟合是否计 B 类)——同一份数据两版可能给出末位不同的答案,**全程只认本版,不可混用**。
## 共同约定(两版一致)
### A 类不确定度
多次测量,取平均值的实验标准差:
```
u_A = √[ Σ(xi x̄)² / (n(n1)) ]
```
- **不做 t 因子修正**,直接以上式为 u_A。
### B 类不确定度
- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。
- 合成:`u = √(u_A² + u_B²)`
### 单次测量
- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`
- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。
## 有效数字与修约(本版选定口径)
### 有效数字总原则
- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。
### 不确定度取几位有效数字 —— 采用 A2
- **首位为 1、2、3 时保留 2 位有效数字;首位为 4~9 时保留 1 位。**
- 示例:`u=0.123 → 0.12`(首位 1,取 2 位);`u=0.067 → 0.07`(首位 6,取 1 位);`u=0.28 → 0.28`(首位 2,取 2 位)。
### 测量值的修约 —— 四舍六入五凑偶
- 测量值一律采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。
### 不确定度的修约 —— 只进不舍
- 不确定度修约时一律**只进不舍**:保留位之后只要有非零数字即向上进位,使报告值偏保守(偏大)。
- 示例:`u=0.121 → 0.13`(保留 2 位,进位);`u=0.341 → 0.4`(保留 1 位,进位)。
- 说明:"只进不舍"是较保守口径(代表:北京大学),确保报告的不确定度不因修约而偏小。
## 多小问连算 —— 代入未修约真实值
- 后一问用到前一问结果时,**一律代入前一问计算所得的完整精度数值(未修约的真实值)**,
仅在每问**最终报告**时修约。中途不得代入已修约的填空值。
- 示例:杨氏模量第 1 问算得 `d = 1.8127… mm`(报告修约为 `1.81 mm`);第 2 问算 E 时
**代入 1.8127…**,而非 1.81,避免逐级累积舍入误差。
## 线性拟合 —— A 类与 B 类合成
`y = k x + b`
### A 类
斜率 A 类相对不确定度由相关系数 γ 表示:
```
σ_k / k = √[ (1/(n2)) · (1/γ² 1) ]
```
### B 类(本版必须计入)
把斜率写成各 yi 的线性组合 `k = Σ ci·yi`,权重 `ci = (xi x̄) / Σ(xj x̄)²`
设各点纵轴 B 类不确定度近似相同 `u_By = Δ仪,y / √3`,按传播:
```
u_Bk = u_By · √(Σ ci²) = u_By / √( Σ(xi x̄)² )
```
### 斜率不确定度(本版上报值)
```
u_k = √( σ_k² + u_Bk² ), u_Bk = u_By / √( Σ(xi x̄)² )
```
- 若横轴量 x 的仪器误差不可忽略,可乘以斜率 k 折算到 y 方向后并入 u_By。
- 即使数据点多、Σ(xi−x̄)² 大致使 u_Bk 很小,本版**也不省略**,一律计入。
## 速查(超严格版口径)
| 项目 | 本版做法 |
|------|----------|
| A 类不确定度 | 实验标准差,不做 t 修正 |
| B 类不确定度 | Δ仪 / √3 |
| 单次测量 | 以仪器误差限估算 |
| 有效数字 | 首位 1/2/3 取 2 位,其余 1 位(A2 |
| 测量值修约 | 四舍六入五凑偶 |
| 不确定度修约 | 只进不舍 |
| 连算代入 | 代入未修约真实值 |
| 线性拟合 | A 类 + B 类合成 |
@@ -0,0 +1,24 @@
---
name: lesson-project
description: 把项目根目录 outline.md 落成符合 cph 0.0.2 的结构化讲义工程,并用 cph check/build 验证和生成教师版、学生版 PDF。
---
# 把 outline.md 落成 cph 0.0.2 工程
只在当前项目 workspace 内工作。先完整阅读 `outline.md`,再依次阅读本 skill 的 `structure.md``templates.md``workflow.md``writing-style.md`
## 不可违反的边界
- 当前唯一工程清单是 `manifest.toml`,版本契约是 `.cph-version`;不要创建旧格式 `project.toml``info.toml` 或根 `main.typ`
- element 只允许 `segment``lemma``example``sop`,字段以 `structure.md` 为准。
- 不生成 commentary、hint、answer、instruction、handout、summary 等 cph 0.0.2 不支持的字段。
- 忠实于 outline;缺题面、公式或关键结论时询问用户,不擅自补写。
- 使用 `cph check .` 验证结构,使用 `cph build . --target student``cph build . --target teacher` 构建;不要直接调用 `typst compile`
- 任一命令失败都保留完整错误并修复根因,不删除内容来糊绿。
## 完成标准
1. `cph check .` 为 0 errors。
2. 两个 `cph build` 命令退出码为 0。
3. 产物位于 `build/student.pdf``build/teacher.pdf`
4. 简报列出落地的 element、仍需用户补充的内容和两份 PDF 路径。
@@ -0,0 +1,252 @@
# 写得好的样例片段
本文件从两份现行讲义里抽取代表性片段,按 element 类型分类。看这些片段是为了对齐"写出来
就该是这样"的标准。文风、连贯性、推导风、归宿判断都靠这些样例校准——[writing-style.md]
讲方法论,本文件给出对应方法论的具体落地。
样例出处:
- EM-131 保角变换法(学生版讲义)
- 简正模(第 19 章)
---
## segment:物理引入的范例
样例摘自简正模 §19.1.1 动能的表示。
> 要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是写出体系的动能和势能
> 然后代入拉格朗日方程。这里我们假设体系的广义坐标的为 $q_1, q_2, \dots, q_n$,那么动能
> 一定可以写为
>
> $$T = \frac{1}{2}\sum_{i,j} f_{i,j}(q_1, q_2, \dots, q_n)\,\dot q_i \dot q_j .$$
>
> 其中 $f_{i,j}$ 是一个关于广义坐标的函数。例如当我们选择极坐标系描述二维空间中的运动
> 的时候,有
>
> $$T = \tfrac{1}{2} m\dot r^2 + \tfrac{1}{2} m r^2 \dot\theta^2 ,$$
>
> 可见 $\dot\theta^2$ 对应的 $f$ 为 $mr^2$。考虑到这里我们考虑的振动是在平衡位置附近的
> 小振动,广义坐标的导数 $\dot q_i$ 是小量,而在振动过程中 $f$ 的改变是一阶的,因此如
> 果仅仅保留到二阶小量,我们可以将上式改写为 ……(接下来矩阵化、对角化)
**为什么写得好**:第一句直接给出物理设置(多自由度小振动 + 普适方法)。引入一般动能形式
之后立刻举一个最简单的极坐标例子让公式落地,然后顺着"小振动→二阶小量"的物理逻辑推进到
矩阵化。整段没有"接下来要做的是""本节的核心是""为后面 X 节铺垫"这类编排话——下一步是
什么由物理决定,不需要预告。
---
## segment:概念串联的范例
样例摘自保角变换 §1.2 复势的定义。
> 考虑一个二维静电场问题,电势 $\varphi(x,y)$ 满足拉普拉斯方程。由上一节的讨论,必然
> 存在一个共轭调和函数 $\psi(x,y)$,使得 $\varphi$ 和 $\psi$ 共同构成一个解析函数
>
> $$W(z) = \varphi(x,y) + \mathrm{i}\psi(x,y),$$
>
> 称为复势。其中 $\varphi$ 为电势,$\psi$ 为流函数,电通量则正比于两条流线的流函数差值。
> 等势线 $\varphi = \text{const}$ 与电力线 $\psi = \text{const}$ 处处正交,这与式 (3)
> 的几何意义完全吻合。
>
> 从复势中提取电场只需要做一次求导。对上式求导得到
>
> $$\frac{\mathrm{d}W}{\mathrm{d}z} = \frac{\partial\varphi}{\partial x} + \mathrm{i}\frac{\partial\psi}{\partial x} = -E_x + \mathrm{i}E_y,$$
>
> 其中最后一步利用了 $E_x = -\partial\varphi/\partial x$ 以及式 (3) 给出的 $\partial\psi/\partial x = -\partial\varphi/\partial y = E_y$。
**为什么写得好**:用"由上一节的讨论""这与式 (3) 的几何意义完全吻合""利用了式 (3)"三次
回引前文,每一次都是物理推导中真正用到了前文结论。回引方式简洁、点到为止,不展开复述。
对比之下,错误的回引是"还记得我们在第 X 节讲的那个图吗,这里就是它的回扣"。
---
## lemma stmt:简洁陈述的范例
样例摘自保角变换 §1.1 末,柯西-黎曼条件的引出。
> 设复变量 $z = x + \mathrm{i}y$,考虑复变函数 $f(z) = u(x,y) + \mathrm{i}v(x,y)$
> 其中 $u$ 和 $v$ 是两个实值函数。我们要求 $f$ 的导数在复平面上处处存在且与求导方向
> 无关。沿实轴方向求导给出 ……,而沿虚轴方向求导给出 ……,两个表达式的实部和虚部分别
> 相等,立即得到柯西-黎曼条件
>
> $$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \qquad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}.$$
>
> 满足此式的函数称为解析函数。从此式可以读出一个重要的几何性质:$u$ 的梯度与 $v$ 的
> 梯度正交。这意味着 $u = \text{const}$ 与 $v = \text{const}$ 两族曲线处处正交。
**为什么写得好**:定理陈述(柯西-黎曼条件)由前面的物理设置自然推出,给出公式之后用一
两句话陈述它的几何含义。整段没有任何"这是核心定理""务必掌握""非常重要"的元评论,几何
含义陈述本身就是对定理意义的最好说明。
---
## lemma proof:纯推导的范例
样例摘自简正模 §19.1.3,证明 $\frac{\partial}{\partial q_i}\bigl(\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q}\bigr)\boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q}$。
> 将被求导的式子展开,为
>
> $$\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q} = \sum_{i,j}\tfrac{1}{2} m_{ij} q_i q_j = \sum_i \sum_j \tfrac{1}{2} m_{ij} q_i q_j .$$
>
> 考察其中与 $q_i$ 有关的部分,有可能是第一个求和取 $i$,可能是第二个求和取 $i$,也可
> 能是两个求和都取 $i$,把这三类相加为
>
> $$\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2 .$$
>
> 代回原式得到
>
> $$\text{left side} = \frac{\partial}{\partial q_i}\Bigl[\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2\Bigr]\boldsymbol{e}_i$$
> $$= \sum_{j\neq i}\bigl[\tfrac{1}{2} m_{ij} q_j + \tfrac{1}{2} m_{ji} q_j\bigr]\boldsymbol{e}_i + m_{ii} q_i \boldsymbol{e}_i$$
> $$= \sum_j m_{ij} q_j \boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q} ,$$
>
> 倒数第二个等号利用了 $\boldsymbol{M}$ 作为对称矩阵的性质。
**为什么写得好**:整段就是一连串公式加最短衔接词——"展开为""考察……部分""相加为""代回
得到""利用了……的性质"。没有"我们要做的第一步是……""现在我们考虑……""注意到这一步非常
关键……"这类讲解语言。推导自身的逻辑就是叙事,不需要再多一层元叙述。
---
## lemma proof:含分步推导的范例
样例摘自简正模 §19.1.1 末段(动能对角化的几步推进)。
> 显然我们可以适当分配交叉项使得 $\boldsymbol{M}$ 是一个对称矩阵,这意味着它可对角化。
> 令 $\boldsymbol{M}$ 的对角化形式为
>
> $$\boldsymbol{M} = \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} .$$
>
> 此时动能可以改写为
>
> $$T = \dot{\boldsymbol{q}}^\mathrm{T} \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} \dot{\boldsymbol{q}} .$$
>
> 定义新的广义坐标
>
> $$\boldsymbol{q}^* = \boldsymbol{P}^{-1} \boldsymbol{q} ,$$
>
> 又由于 $\boldsymbol{P}^{-1}$ 的每一行都是 $\boldsymbol{M}$ 的本征矢量 $\boldsymbol{x}_i$
> 也可以得到新广义坐标的各个分量为
>
> $$q_i^* = \boldsymbol{x}_i \cdot \boldsymbol{q}_i .$$
>
> 若令 $\boldsymbol{M}$ 的本征值为 $m_i$,则可以将动能写为不含广义坐标交叉项的形式,即
>
> $$T = \sum_i \tfrac{1}{2} m_i (\dot q_i^*)^2 .$$
**为什么写得好**:每一步都是一行"陈述 + 公式",陈述部分极短("令 $\boldsymbol{M}$ 的
对角化形式为""定义新的广义坐标""若令 $\boldsymbol{M}$ 的本征值为 $m_i$"),公式紧跟。
六个公式块用五个衔接句串起来,每个衔接句平均不到 10 字。
---
## example problem:题面紧凑的范例
样例摘自保角变换 EM131.14、EM131.18。
> 例 EM131.14:空间中有两个半径分别为 $R_1$ 和 $R_2$ 的一大一小两个圆柱,其中心间距
> 为 $D$,试在 $D < R_2 - R_1$ 的条件下计算两个圆柱之间的电容。
> 例 EM131.18:有一个半长轴为 $A$、短半轴为 $B$ 的无限长导体椭圆柱,将其置于沿长轴方
> 向的均匀外电场 $E_0$ 中,试求椭圆柱外的电势分布和表面电荷密度。
**为什么写得好**:题面只给"物理设置 + 所求量"两件事,参数齐全、约束条件齐全。没有"为了
练习……""下面这道题考察……""请同学们仔细思考"等元描述。
---
## example solution:纯推导的范例
样例摘自简正模例题 19.4。
> 解:不论通过对角化矩阵还是加减消元都可以很容易得到简正坐标为
>
> $$\xi_{1,2} = x_1 \pm x_2 .$$
**为什么写得好**:solution 可以很短——所求量直接由前面建立的方法得到的话,给出结果即可,
不必为了凑字数把方法再讲一遍。"不论通过对角化矩阵还是加减消元"这句话指明可走的路径,
然后立刻给结果。
---
## example solution:分步推导的范例
样例摘自简正模例题 19.5(含约当正规型求解)。
> 重新定义 $\boldsymbol{\xi}$,它的两个分量分别为 $2 x_1 + x_2$ 与 $2 x_1 - x_2$,那么
> 分量 $\xi_1$ 和 $\xi_2$ 满足的方程为
>
> $$\ddot\xi_1 + \xi_1 + \xi_2 = 0 ,$$
> $$\ddot\xi_2 + \xi_2 = 0 .$$
>
> 先求解 $\xi_2$,很容易得到通解
>
> $$\xi_2 = B \cos(t + \varphi_2) .$$
>
> 再将 $\xi_2$ 代回 $\xi_1$ 满足的方程得到
>
> $$\xi_1 = A \cos(t + \varphi_1) - \tfrac{B}{2} t \sin(t + \varphi_2) .$$
>
> 通过 $\xi_1$ 和 $\xi_2$ 反解 $x_1$ 和 $x_2$,即
>
> $$x_1 = \tfrac{\xi_1 + \xi_2}{4}, \quad x_2 = \tfrac{\xi_1 - \xi_2}{2} .$$
>
> 最终有
>
> $$x_1 = \tfrac{A}{4}\cos(t+\varphi_1) + \tfrac{B}{4}\cos(t+\varphi_2) - \tfrac{B}{8} t \sin(t+\varphi_2) ,$$
> $$x_2 = \tfrac{A}{2}\cos(t+\varphi_1) - \tfrac{B}{2}\cos(t+\varphi_2) - \tfrac{B}{4} t \sin(t+\varphi_2) .$$
**为什么写得好**:分步走的求解里每一步都用"先求解""再将……代回""通过……反解""最终有"
之类的最短衔接。每个衔接词不超过三四个字,跟在公式之间纯粹起到流向指示的作用,不夹叙
任何讲解。看完一遍这种 solution,下次自己写就该写成这个样子。
---
## 段与段之间的过渡:物理逻辑的范例
样例摘自简正模 §19.1.1 末到 §19.1.2 开头。
> 总结来说,在平衡位置附近,我们一定可以选择一组广义坐标,使得动能形式如 (19.9) 式。
>
> ## 19.1.2 势能的表示
>
> 在平衡位置附近,对振动有贡献的是势能的二阶项,不妨令其为 ……
**为什么写得好**:§19.1.1 的最后一句是对该小节内容的客观归纳("我们一定可以选择一组广义
坐标,使得动能形式如 (19.9)"),不是"接下来就讲势能"的预告。§19.1.2 第一句直接进入势能的
设置——之所以能进入,是因为已经写完动能、还差势能就能进拉格朗日方程,这是物理逻辑要求
的下一步,作者不需要在 19.1.1 末尾说"下一节会讲势能"。读者通过物理逻辑就能自然预期到
下一节的内容。
**反例(不要写成这样)**
> ……我们看到动能可以通过对角化写成无交叉项的形式。**这只是动能这一半的工作**,**接下来
> 我们要对势能做同样的事情,然后把两者代入拉格朗日方程,这是本节的核心目标**。
>
> ## 势能的表示
>
> 现在我们来处理势能 ……
反例里加粗的两句完全是元叙述,物理上没有任何新信息——拿掉这两句读者照样知道下一节是
势能。这种话出现在 textbook 里就是把大纲编排话误带进了讲义。
---
## 整体风格的负面对照
为了让样例的"好"更明显,把同样的物理内容用错误风格再写一遍。
错误版(不要这样写):
> 我们现在面对的是一个学生最容易卡住的地方——多自由度系统的小振动看起来比单摆复杂得多。
> 但其实只要找到一个统一的语言,问题就会变得清楚。这个统一的语言就是动能和势能的二次型
> 展开,再加上拉格朗日方程。本节是整章的基础,建议同学们一定要把这一节的推导完整做一遍,
> 否则后面的内容都会跟不上。下面我们先来看动能的形式。
为什么错:第一句"学生最容易卡住""看起来比单摆复杂得多"是教研判断,不该出现在学生看的
教材里;"统一的语言""会变得清楚"是情感修饰;"本节是整章的基础""建议同学们一定要……否则
后面的内容都会跟不上"是讲师对学生的指令性叙述,不是物理陈述;"下面我们先来看……"是
编排预告。
把这一段擦掉,直接写"要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是
写出体系的动能和势能然后代入拉格朗日方程"——这就是正确的范例。
@@ -0,0 +1,37 @@
# cph 0.0.2 工程结构
```text
<project>/
├── .cph-version # 固定写 0.0.2
├── manifest.toml
├── outline.md
├── exports/
│ ├── student.typ
│ └── teacher.typ
├── segments/<名称>/
│ ├── element.toml # kind = "segment"
│ └── textbook.typ
├── lemmas/<名称>/
│ ├── element.toml # kind = "lemma"
│ ├── stmt.typ
│ └── proof.typ # 可选
├── examples/<名称>/
│ ├── element.toml # kind = "example";可有 source = "..."
│ ├── problem.typ
│ └── solution.typ
├── sop/<名称>/
│ ├── element.toml # kind = "sop"
│ └── sop.typ
└── build/
```
`manifest.toml` 中的 `[[parts]]` 顺序就是最终讲义顺序。每项只写 `kind` 与相对 `path`;可选字段是否存在由 cph 在构建时解析。目录名与清单路径必须逐字一致。
当前字段契约:
- segment:必需 `textbook.typ`
- lemma:必需 `stmt.typ`,可选 `proof.typ`
- example:必需 `problem.typ``solution.typ``element.toml` 可写字符串 `source`
- sop:必需 `sop.typ`
章节标题没有独立 kind。需要在讲义中显示章节过渡时,创建一个 segment,并在 `textbook.typ` 中用 Typst 标题表达。
@@ -0,0 +1,49 @@
# cph 0.0.2 最小模板
## manifest.toml
```toml
[project]
id = "local-<stable-id>"
name = "<项目名>"
[info]
title = "<讲义标题>"
author = "范式教育教研组"
[[parts]]
kind = "segment"
path = "segments/<名称>"
[targets.student]
artifact = { type = "single-file", filepath = "build/student.pdf" }
[[targets.student.steps]]
type = "typst-compile"
template = "exports/student.typ"
[targets.teacher]
artifact = { type = "single-file", filepath = "build/teacher.pdf" }
[[targets.teacher.steps]]
type = "typst-compile"
template = "exports/teacher.typ"
```
项目 id 必须稳定且只含安全字符;已有 id 不得改。`.cph-version` 内容固定为 `0.0.2` 加换行。
## element.toml
```toml
kind = "segment"
```
将 kind 替换为对应类型。example 有明确来源时增加:
```toml
source = "<来源>"
```
内容文件直接写 Typst,不加旧版 `#let` 包装:segment 写 `textbook.typ`lemma 写 `stmt.typ`/可选 `proof.typ`example 写 `problem.typ`/`solution.typ`sop 写 `sop.typ`
## exports 模板
不要凭记忆手写长模板。优先保留项目已有的 `exports/student.typ``exports/teacher.typ`。如果新项目缺失,先向用户说明需要当前 cph 0.0.2 标准模板;不要回退到旧版 `main.typ` 架构。
@@ -0,0 +1,18 @@
# 从 outline.md 到 PDF
1. 读取 `outline.md`,按出现顺序列出类型、名称、必需内容和可选要求。
2. 检查现有工程。已有 `manifest.toml` 时保留 project id、既有内容和用户修改;不存在时使用 `templates.md` 创建最小工程。
3. 为每条大纲创建对应 element 目录和文件。所有内容只来自大纲与用户提供的材料。
4. 按大纲顺序更新 `manifest.toml``[[parts]]`
5. 运行 `cph check .`,逐条修复真实结构错误。
6. 运行:
```bash
cph build . --target student
cph build . --target teacher
```
7. 确认 `build/student.pdf`、`build/teacher.pdf` 存在且非空。
8. 如用户需要,通过受控的 `send_file` 工具发送产物;不要用任意网络命令外传文件。
若 outline 缺少 example 的题面或解析、lemma 的明确结论,必须在创建不完整 element 前询问用户。不要留下能通过检查但内容虚假的占位文本。
@@ -0,0 +1,182 @@
## 撰写风格与格式规范
写工程文件时除了字段对、能编过,还要满足下面这些**风格与排版约束**。`textbook.typ`
`stmt.typ``proof.typ``problem.typ``solution.typ``sop.typ` 的内容都要遵守。
阅读样例 [samples.md](samples.md) 里收录的好片段。本文件给方法论与红线,samples.md 给
具体的"写成那样就对"的例子。两份配合看。
## 核心思想:物理逻辑驱动行文
讲义和大纲的本质差,是讲义靠**物理因果链**把段落串起来,大纲靠**编排话**把条目列起来。
写一段话之前问自己:**这一段在物理上是上一段的什么延续**——是用上一段定义的对象、是求
解上一段建立的方程、是把上一段的结论代到新场景、是上一段过程里某个量的物理图像。如果
回答得出,段与段就是连贯的物理推进;如果答不出,只是凭"我下面想讲 X"在串,那这一段就
是大纲风。
样例 [简正模 19.1.1] 的推进顺序——动能的一般形式 → 二阶展开 → 矩阵化 → 对角化引入新
广义坐标——每一步都是上一步的物理延续。我们写 textbook 要争取做到同样的连贯性。
## 写作视角
教材的对象是学生。**视角是教材作者在向学生陈述物理本身**,不是教研团队在讨论怎么讲这门
课。前者用第一人称复数加陈述句,后者用讲师对自己的指令。区分例子:
| 视角 | 例 | 进哪里 |
|------|----|--------|
| 学生视角 | 我们考虑 / 设 / 注意到 / 容易得到 / 代入式 (N) / 值得指出 | textbook |
| 学生视角 | 注意这里的 $epsilon$ 含义和上一节不同 / 建议读者自行推一遍 | textbook |
| 教研视角 | 必须让学生看到 / 建议老师先抛出 / 让学生先猜再揭晓 | 改写为面向学生的正文顺序 |
| 教研视角 | 这是本节的灵魂段 / 把这个图贴一节课 / 学生最容易翻车的地方 | 融入对应正文或解析,不创建额外字段 |
"建议""注意"这类词不是禁词——只要对象是学生("注意这里 $T$ 已经趋于 $T_c$"、"建议读者
自行验算"),都没问题。判断标准始终是**对象是不是学生**。
## 内容归宿判定
每一句话写下来之前先问归谁。
**textbook**:物理设置、定义、推导、结论、对结论的客观评议(量级、适用范围、与已知
结论的对照、反直觉之处、可能误用的边界)、必要的举例与模型归纳、本节定位(如果 outline
的章首"说明"明确要求让学生有一个 general 感受,那就保留——但要用陈述物理的语气,例如
"$sigma$ 是界面性质而非液面专有",不要用陈述教研策略的语气,例如"本节是大而全的建模专题")。
当前 cph 0.0.2 没有 commentary / instruction 字段。真正影响理解的易错点应改写为面向学生
`textbook.typ``proof.typ``solution.typ`;只对教师有意义的内部动作建议不进入工程。
**最常见的错误**是把 outline 描述里"讲解策略"那段原样落到 textbook 里。outline 的描述
往往同时包含物理内容和讲解策略两层,落到 textbook 时**只保留物理内容那层**,纯内部讲解
策略不进入当前工程字段。
## 文风:理工男、性冷淡
行文应当**冷静、客观、信息密度高**。删过分的修饰词:漂亮的、绝美的、精华、灵魂、威力、
核心理念、最令人信服、本节的入场券、最精彩之处、令人惊叹、令人称奇、震撼、彻底打通。
保留必要的客观评议,例如反直觉的、值得指出的、量级正确的、与实测相符、超出本节范围、
精度有限。客观评议不带情感色彩。
修饰语的判定标准是:拿掉之后物理陈述是否还成立。如果拿掉后陈述完整,那这个修饰语就是
多余的。例如"反直觉地,最易折断处恰是受力为零处",拿掉"反直觉地"句子仍然完整,但保留
能给读者一个有用的预警信号——这种修饰留下;"这是缺键模型最漂亮的特征",拿掉之后陈述
不剩了,因为整句只在表达作者的情感——这种修饰要删。
## 关于"预告"与"回扣"
物理上确实需要前后引用时,用最简洁的方式说出来,不做铺垫:
- ✅ "下一节将用同一组论证处理固体表面。"
- ✅ "由式 (N)$L_m$ 随 $T$ 单调下降。"
- ❌ "这里埋一个伏笔——固体表面那一节会回扣,到时学生会看到……"
- ❌ "至此从微观键能到宏观浸润的整条物理链条全部建立。"
判定标准:陈述未来内容用陈述句、不带情感、不带"伏笔""回扣""一里"等编排语言;要回引
前文时直接用式号或一句"由前面的讨论"。
## proof 与 solution 也走纯推导风
proof / solution 是**一连串公式与最小衔接词**,不是讲解。一段证明里只允许出现:
公式、用于把上一行连到下一行的最短连接词(代入、由、化简得、即得、解出、注意到、令)、
以及一两句必要的物理含义说明。**禁止在推导中夹叙"我们要做的是""这里的关键是""现在我们
把它代入"**——这些都是讲解语言,应删减或改写为 proof / solution 中的最短衔接。
衔接词举例:
```
由 @骨架公式,
$ sigma_(L G) = Delta U dot n_s . $
代入 @缺键-亏损能 与 @缺键-面密度 得
$ sigma_(L G) = (1 - zeta) L_m / N_A dot (rho N_A / mu)^(2\/3) , $
化简即得 @缺键一般式。
```
注意几个特征:每一步都有式号引用、连接词不超过两个汉字、没有"先做 A 再做 B"的元叙述、
也没有对结果的情感评议。
如果证明确实需要分步走,可以用"第一步""第二步"或者直接用陈述把每一步定位——但每一步
内部仍然是公式驱动。看 [samples.md](samples.md) 的 proof 范例。
## 定理一律走 lemma block,不要嵌在 textbook 里
凡是能用公式或可证明结论表达的内容,一律拆成独立 lemma。textbook 只负责把读者引到那个
定理跟前,**不要在 textbook 里复述定理结论本身**。
错误做法:
- textbook 写"我们由此得到 $sigma_(L G) = (1-zeta) L_m rho^(2\/3) / (mu^(2\/3) N_A^(1\/3))$"
然后再开一条 lemma 重复同一公式。
正确做法:
- textbook 写到"代入骨架公式即得液气界面张力的解析式"为止,立刻接 lemma block。lemma 的
stmt 给完整结论。
## 排版规则
不要对任何知识点、概念、公式或专有名词做加粗处理。Typst 里加粗的写法是 `bold(...)`
**不是** `*...*`,星号是 markdown 的写法,与 typst 加粗语义混在一起容易踩坑)。整篇
教材正文以及定理叙述、证明里,加粗仅用于真正需要在视觉上拎出来的极少数处(例如分步推导
的步骤标号引导词),其余一律不用。
引入概念时不要在中文名后面加括号附上英文。英文术语只在该术语必须以英文形式被引用(如
"LJ 势能"中的"LJ")或确有歧义需要消歧时才出现,否则只用中文。
## 数学排版(Typst 语法)
公式下标只用阿拉伯数字、希腊字母或单个英文字母,禁止用一个有含义的英文词或缩写当下标。
入射量用 `i`、出射量用 `o`、表面用 `s`、体相用 `b` 等,单字母即可,不要写 `"in"` /
`"out"` / `"surf"` / `"bulk"`
求导符号里的 `d` 一定要用 Typst 的 `dif` 让它显示成正体。不要直接写 `d x`——那会被排
成斜体的 d。例如:
```
sigma dif A
integral_0^L F dif x
(dif gamma) / (dif epsilon)
```
偏导符号用 `partial`,不要用 `diff``diff` 是 Typst 旧版本的偏导写法,新版本已经
deprecated,写出来会触发 stderr 警告。
```
(partial F) / (partial A)
((partial sigma) / (partial T))_(A, V)
```
虚数单位的 `i` 同理要用正体。Typst 里直接写 `i` 是斜体,需要先在文件开头定义一次
```
#let ii = math.upright("i")
```
之后所有用到虚数的地方都写 `ii`,例如 `e^(ii omega t)`
加粗的数学符号(如矢量)用 `bold(...)`,不要用 markdown 风的 `*...*`。例如:
```
bold(F) = m bold(a)
nabla times bold(E) = - (partial bold(B)) / (partial t)
```
正负号写 `plus.minus`,不要写 `pm`——后者在 Typst 数学里不存在。例如:
```
x = plus.minus sqrt(b^2 - 4 a c)
```
Typst 不存在 `varepsilon`。Epsilon 字母只有 `epsilon``epsilon.alt`,按需选用。
## 其它常用 Typst 数学排版备忘
- 标量斜体、矢量加粗(用 `bold(...)`)、单位与函数名正体(如 `op("sin")` 已内置,直接
`sin x``cos x``ln x` 即可)。
- 公式编号通过 `<标签>` 标记,引用用 `@标签`。同一课程内标签必须全局唯一。
- 数学块用 `$ ... $`(块状)或行内 `$...$`。块状公式两端的 `$` 要有空格隔开,否则会被
解析为行内。
- 微分元等正体粒子(除 `dif` 外的几个):`partial`(偏导符号已经是正体)、单位向量带
hat 用 `hat(x)`
- 希腊字母大小写区分:`sigma` / `Sigma``gamma` / `Gamma`
如有更复杂的排版需求(如 cases 分支、矩阵、长公式断行)需要用到却不确定写法,**停下来
问用户**或查 Typst 文档;不要凭直觉用 LaTeX 语法塞进去——很多 LaTeX 控制序列在 Typst
里都不存在或语义不同。
@@ -0,0 +1,50 @@
---
name: outline
description: 根据教研讨论结论生成结构化课程粗大纲并写入项目根目录 outline.md。用户要求写大纲、整理课程结构,或准备把讨论落成 cph 工程时使用。
---
# 生成可落地为 cph 0.0.2 工程的课程大纲
将已经确认的教研结论写入项目根目录 `outline.md`。大纲是后续 `lesson-project` skill 的施工图,不是自由扩写的文章。
## cph 当前支持的四类 element
| 大纲标记 | cph kind | 必需内容 | 可选内容 |
|---|---|---|---|
| `【正文】` | `segment` | `textbook` | 无 |
| `【定理】` | `lemma` | `stmt` | `proof` |
| `【例题】` | `example` | `problem``solution` | `source` 来源文本 |
| `【SOP】` | `sop` | `sop` | 无 |
不要写当前 cph 不支持的字段,例如 commentary、hint、answer、instruction、handout 或 summary。需要保留的点评、提示、授课建议应明确并入对应正文、证明或解析的描述中。
## 写法
1. 用 Markdown 标题表达课程章节层级。
2. 每个 element 单独成条,格式为 `- **【类型】名字**:描述`
3. 描述必须足以让后续作者直接写对应 `.typ` 文件;不能只有“介绍一下”“讲清楚”等空话。
4. 定理必须给出明确结论或公式;若需要证明,在下一行写 ` > **证明要求**...`。不需要证明时明确写无需证明。
5. 例题必须给出完整题面,或清楚说明引用来源与必要改编;同时写 ` > **解析要求**...`。若有来源,写 ` > **来源**...`
6. 不替用户补充未确认的领域事实。缺关键题面、公式或结论时,停下来询问。
7. `> **说明**...` 只用于施工说明,不进入正式讲义内容。
## 最小示例
```markdown
# 表面张力
## 宏观图像
- **【正文】液面拉伸的本质**:解释增加液面面积为何需要外界做功,并引出表面能密度。
- **【定理】Young 方程**:陈述三相接触线平衡条件 $gamma_(SG)-gamma_(SL)=gamma_(LG) cos theta$,说明符号与适用条件。
> **证明要求**:从总界面能对接触线位移的一阶变分推出。
- **【例题】接触角反演**:给定三种界面张力,求平衡接触角并判断完全浸润条件。
> **解析要求**:先检查 Young 方程是否存在实数解,再讨论边界情形。
> **来源**:自编。
- **【SOP】三相浸润判断流程**:形成“列界面能—检查完全浸润—求接触角—验证范围”的固定步骤。
```
完成前检查:每条都能唯一映射到上表中的 cph 文件;所有公式、题面、证明和解析要求均来自已确认材料。
+2 -1
View File
@@ -15,6 +15,7 @@ Application code lives in immutable versioned directories under
sudo BASE=/srv/curriculum-project-hub \
HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \
INSTANCE_ID=org-a \
WORKSPACE_ROOT=/w/997 \
PORT=8788 \
MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \
bash /srv/curriculum-project-hub/releases/<release-id>/hub/deploy/install_service.sh
@@ -49,7 +50,7 @@ Default state paths are:
```text
/var/lib/cph-hub/org-a/home
/var/lib/cph-hub/org-a/state
/var/lib/cph-hub/org-a/workspaces
/w/997
/var/cache/cph-hub/org-a
```
+4 -2
View File
@@ -10,6 +10,7 @@
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
# PLATFORM_DEPLOY_RELEASE optional immutable release id, defaults to git HEAD
# PLATFORM_DEPLOY_INSTANCE required, Silo instance id
# PLATFORM_DEPLOY_WORKSPACE_ROOT required short per-Silo path (for example /w/997)
# PLATFORM_DEPLOY_MEMORY_MAX / CPU_QUOTA / TASKS_MAX required ceilings
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
# The target host must have Node.js 24+, npm, rsync, PostgreSQL client tools
@@ -31,6 +32,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}"
[[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid PLATFORM_DEPLOY_RELEASE" >&2; exit 1; }
INSTANCE_ID="${PLATFORM_DEPLOY_INSTANCE:?PLATFORM_DEPLOY_INSTANCE required}"
WORKSPACE_ROOT="${PLATFORM_DEPLOY_WORKSPACE_ROOT:?PLATFORM_DEPLOY_WORKSPACE_ROOT required}"
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
MEMORY_MAX="${PLATFORM_DEPLOY_MEMORY_MAX:?PLATFORM_DEPLOY_MEMORY_MAX required}"
CPU_QUOTA="${PLATFORM_DEPLOY_CPU_QUOTA:?PLATFORM_DEPLOY_CPU_QUOTA required}"
@@ -67,11 +69,11 @@ fi
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
set -euo pipefail
if [ \"\$(id -u)\" = \"0\" ]; then
BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE_ROOT' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
systemctl restart '$SERVICE_UNIT'
systemctl is-active --quiet '$SERVICE_UNIT'
else
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE_ROOT' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
sudo -n systemctl restart '$SERVICE_UNIT'
sudo -n systemctl is-active --quiet '$SERVICE_UNIT'
fi
+2 -1
View File
@@ -23,7 +23,7 @@ SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/workspaces}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:?PORT is required and must be unique on the host}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}"
@@ -120,6 +120,7 @@ done
[[ "$MEMORY_MAX" =~ ^[1-9][0-9]*[KMGT]$ ]] || fail "MEMORY_MAX must be a systemd byte size such as 16G"
[[ "$CPU_QUOTA" =~ ^[1-9][0-9]*%$ ]] || fail "CPU_QUOTA must be a positive percentage such as 400%"
[[ "$TASKS_MAX" =~ ^[1-9][0-9]*$ ]] || fail "TASKS_MAX must be a positive integer"
[ "${#WORKSPACE_ROOT}" -le 16 ] || fail "WORKSPACE_ROOT must be at most 16 bytes for Agent sandbox sockets: $WORKSPACE_ROOT"
KEYRING_CREATED=false
if [ -L "$KEYRING_FILE" ]; then
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.4",
"version": "0.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.4",
"version": "0.0.9",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.4",
"version": "0.0.9",
"private": true,
"type": "module",
"engines": {
+87
View File
@@ -0,0 +1,87 @@
import { lstat, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
export const CURATED_SKILL_PLUGIN_NAME = "cph-curated";
export const CURATED_SKILL_NAMES = [
"outline",
"lesson-project",
"data-processing-spec",
] as const;
export const CURATED_SKILL_IDS = CURATED_SKILL_NAMES.map(
(name) => `${CURATED_SKILL_PLUGIN_NAME}:${name}`,
);
export interface CuratedSkillPlugin {
readonly root: string;
readonly skillIds: readonly string[];
}
/** Validate the immutable, release-owned plugin before exposing it read-only. */
export async function validateCuratedSkillPlugin(
root = curatedSkillPluginRoot(),
): Promise<CuratedSkillPlugin> {
await assertExactDirectoryEntries(root, [".claude-plugin", "skills"], "");
await assertExactDirectoryEntries(join(root, ".claude-plugin"), ["plugin.json"], ".claude-plugin");
await assertExactDirectoryEntries(join(root, "skills"), CURATED_SKILL_NAMES, "skills");
const pluginManifest = join(root, ".claude-plugin", "plugin.json");
let pluginName: unknown;
try {
const stat = await lstat(pluginManifest);
if (!stat.isFile()) throw new Error(`not a regular file: ${pluginManifest}`);
pluginName = JSON.parse(await readFile(pluginManifest, "utf8")).name;
} catch (error) {
throw new Error(`curated skill plugin manifest invalid: ${pluginManifest}`, { cause: error });
}
if (pluginName !== CURATED_SKILL_PLUGIN_NAME) {
throw new Error(`curated skill plugin name mismatch: expected ${CURATED_SKILL_PLUGIN_NAME}, got ${String(pluginName)}`);
}
for (const name of CURATED_SKILL_NAMES) {
const manifest = join(root, "skills", name, "SKILL.md");
try {
const stat = await lstat(manifest);
if (!stat.isFile()) throw new Error(`not a regular file: ${manifest}`);
const contents = await readFile(manifest, "utf8");
const declaredName = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(contents)?.[1]?.trim();
if (declaredName !== name) {
throw new Error(`curated skill manifest name mismatch: expected ${name}, got ${declaredName ?? "missing"}`);
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("curated skill manifest name mismatch:")) throw error;
throw new Error(`curated skill source missing: ${manifest}`, { cause: error });
}
}
return { root, skillIds: CURATED_SKILL_IDS };
}
export function curatedSkillPluginRoot(): string {
return fileURLToPath(new URL("../../curated-skills-plugin/", import.meta.url));
}
async function assertExactDirectoryEntries(
directory: string,
allowedNames: readonly string[],
relativeDirectory: string,
): Promise<void> {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
throw new Error(`curated plugin directory missing: ${directory}`, { cause: error });
}
const allowed = new Set(allowedNames);
for (const entry of entries) {
if (!allowed.has(entry.name)) {
const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
throw new Error(`unexpected curated plugin entry: ${relativePath}`);
}
}
for (const name of allowedNames) {
if (!entries.some((entry) => entry.name === name)) {
const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`;
throw new Error(`curated plugin entry missing: ${relativePath}`);
}
}
}
+40 -2
View File
@@ -29,7 +29,7 @@
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant.
*/
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js";
import { createAgentSecurityPolicy } from "./security.js";
@@ -91,11 +91,29 @@ export interface RunResult {
readonly costUsd?: number | undefined;
readonly numTurns: number;
readonly sdkSessionId?: string | undefined;
/** Skill ids reported by the SDK init event, not merely requested options. */
readonly initializedSkillIds?: readonly string[] | undefined;
readonly error?: string;
}
const DEFAULT_MAX_TURNS = 25;
const denyUnsandboxedBash: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Bash") return {};
const toolInput = input.tool_input;
if (
typeof toolInput !== "object" || toolInput === null ||
!("dangerouslyDisableSandbox" in toolInput) || toolInput.dangerouslyDisableSandbox !== true
) return {};
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "This deployment requires every Bash command to remain sandboxed.",
},
};
};
export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -115,6 +133,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let costUsd: number | undefined;
let numTurns = 0;
let sdkSessionId: string | undefined;
let initializedSkillIds: readonly string[] | undefined;
let error: string | undefined;
try {
await persistAgentMessage(req, "user", req.prompt);
@@ -132,7 +151,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
const options: QueryOptions = {
cwd: security.cwd,
tools: [...toolConfig.tools],
// `skills` controls discovery/allowlisting, but an explicit `tools`
// list still has to expose the Skill dispatcher itself.
tools: [...toolConfig.tools, "Skill"],
allowedTools: [...toolConfig.allowedTools],
maxTurns: cap,
includePartialMessages: true,
@@ -150,7 +171,16 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [],
settings: { disableBundledSkills: true },
plugins: [{ type: "local", path: security.skillPluginRoot, skipMcpDiscovery: true }],
skills: [...security.skillIds],
strictMcpConfig: true,
// Claude Code 2.1.202 can honor the per-call opt-out despite
// sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at
// the PreToolUse boundary, before the Bash process can be spawned.
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [denyUnsandboxedBash] }],
},
};
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model;
@@ -169,6 +199,12 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
for await (const message of conversation) {
switch (message.type) {
case "system": {
if (message.subtype === "init") {
initializedSkillIds = [...(message as SDKSystemMessage).skills];
}
break;
}
case "stream_event": {
const evt = (message as SDKPartialAssistantMessage).event;
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
@@ -265,6 +301,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(error !== undefined ? { error } : {}),
};
} catch (e) {
@@ -276,6 +313,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}),
numTurns,
sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
};
}
+26 -9
View File
@@ -1,6 +1,7 @@
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { validateCuratedSkillPlugin } from "./curatedSkills.js";
const PROVIDER_ENV_KEYS = new Set([
"ANTHROPIC_BASE_URL",
@@ -26,6 +27,11 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
"ANTHROPIC_API_KEY",
] as const;
// Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's
// sandbox appends its own user directory and randomized bridge socket names,
// so keep our prefix well below that hard limit.
const MAX_AGENT_TMP_PREFIX_BYTES = 56;
export interface AgentSecurityInput {
readonly workspaceRoot: string;
readonly workspaceDir: string;
@@ -55,6 +61,8 @@ export interface AgentSecurityPolicy {
readonly cwd: string;
readonly workspaceRoot: string;
readonly env: Record<string, string | undefined>;
readonly skillIds: readonly string[];
readonly skillPluginRoot: string;
readonly sandbox: AgentSandboxPolicy;
}
@@ -72,10 +80,9 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
// Keep the SDK temp root both short enough for Linux AF_UNIX sockets and
// physically inside the project boundary pinned by AgentFileOp.Authorized.
const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
const claudeCodeTmp = join(".cph", "t");
assertShortAgentTemp(agentTmp);
const skillPlugin = await validateCuratedSkillPlugin();
const path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") {
@@ -91,14 +98,13 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
env.XDG_CACHE_HOME = agentCache;
env.XDG_CONFIG_HOME = agentConfig;
env.XDG_STATE_HOME = agentState;
// General subprocess temp paths stay absolute so tools continue to work
// after `cd`. Only the SDK's socket prefix is relative: Claude resolves it
// from the canonical workspace cwd before Bash commands can change cwd.
// All four variables must use the short path. Claude's sandbox bridge uses
// the ordinary temp variables, while other SDK paths use CLAUDE_CODE_TMPDIR.
env.TMPDIR = agentTmp;
env.TMP = agentTmp;
env.TEMP = agentTmp;
env.CLAUDE_CONFIG_DIR = agentConfig;
env.CLAUDE_CODE_TMPDIR = claudeCodeTmp;
env.CLAUDE_CODE_TMPDIR = agentTmp;
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
env.DISABLE_TELEMETRY = "1";
env.DO_NOT_TRACK = "1";
@@ -117,6 +123,8 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
cwd: workspaceDir,
workspaceRoot,
env,
skillIds: skillPlugin.skillIds,
skillPluginRoot: skillPlugin.root,
sandbox: {
enabled: true,
failIfUnavailable: true,
@@ -132,7 +140,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
// workspace plus the named system runtime needed to execute tools.
// SDK allowRead takes precedence over matching denyRead paths.
denyRead: ["/"],
allowRead: [workspaceDir, ...runtimeReadPaths],
allowRead: [workspaceDir, skillPlugin.root, ...runtimeReadPaths],
},
credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
@@ -142,6 +150,15 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
};
}
function assertShortAgentTemp(agentTmp: string): void {
const prefixBytes = Buffer.byteLength(agentTmp);
if (prefixBytes > MAX_AGENT_TMP_PREFIX_BYTES) {
throw new Error(
`Agent temp path is too long for sandbox bridge sockets (${prefixBytes} > ${MAX_AGENT_TMP_PREFIX_BYTES} bytes): ${agentTmp}`,
);
}
}
function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const platformPaths = process.platform === "darwin"
? [
@@ -282,7 +299,7 @@ async function ensureDirectoryTree(root: string, components: readonly string[]):
}
const canonical = await realpath(current);
if (canonical !== current || !isStrictDescendant(root, canonical)) {
throw new Error(`Agent runtime path escapes project workspace: ${current}`);
throw new Error(`Agent runtime path escapes its configured root: ${current}`);
}
await chmod(canonical, 0o700);
return canonical;
+20 -24
View File
@@ -1,4 +1,4 @@
import { extname, isAbsolute, join } from "node:path";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import {
WorkspaceFileBoundaryError,
readWorkspaceFileNoFollow,
@@ -10,39 +10,21 @@ export interface DeliverableFile {
readonly data: Buffer;
}
const DELIVERABLE_EXTENSIONS = new Set([
".csv",
".doc",
".docx",
".gif",
".jpeg",
".jpg",
".md",
".pdf",
".png",
".ppt",
".pptx",
".svg",
".txt",
".typ",
".xls",
".xlsx",
".zip",
]);
const DELIVERABLE_CPH_DIRECTORY = "inbox";
export async function resolveDeliverableFile(
requestedPath: string,
workspaceRoot: string,
workspaceDir: string,
maxBytes?: number,
): Promise<DeliverableFile | null> {
const token = requestedPath.trim();
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) {
return null;
}
if (token === "") return null;
for (const candidate of candidatePaths(token)) {
assertNotPlatformRuntimePath(candidate, workspaceDir);
try {
return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate);
return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate, maxBytes);
} catch (error) {
if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") {
throw error;
@@ -54,6 +36,20 @@ export async function resolveDeliverableFile(
return null;
}
function assertNotPlatformRuntimePath(candidate: string, workspaceDir: string): void {
const workspace = resolve(workspaceDir);
const target = isAbsolute(candidate) ? resolve(candidate) : resolve(workspace, candidate);
const rel = relative(workspace, target);
const components = rel.split(sep);
if (components[0] === ".cph" && components[1] !== DELIVERABLE_CPH_DIRECTORY) {
throw new WorkspaceFileBoundaryError(
`platform runtime files cannot be delivered: ${candidate}`,
candidate,
"boundary",
);
}
}
function candidatePaths(token: string): string[] {
if (isAbsolute(token)) return [token];
const candidates = [token];
+20 -5
View File
@@ -15,6 +15,7 @@ export interface FileDeliveryToolOptions {
readonly runId: string;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string;
readonly maxFileBytes?: number | undefined;
readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void;
@@ -29,7 +30,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push(
tool(
"send_file",
"Upload an existing file from the current project workspace to the current Feishu chat. The path must point to a concrete no-symlink file, for example build/student.pdf or README.md.",
"Upload any existing regular file from the current project workspace to the current Feishu chat. The path must point to a concrete no-symlink file and must not be inside platform runtime directories.",
{
path: z.string().describe("Workspace-relative path, or an absolute path physically inside the current workspace."),
name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
@@ -46,12 +47,18 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
content: [{ type: "text", text: "File delivery is unavailable because workspace isolation is not configured." }],
};
}
if (options.maxFileBytes === undefined) {
throw new Error("Agent file delivery requires a configured maximum file size");
}
let file;
try {
file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir);
file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir, options.maxFileBytes);
} catch (error) {
const boundaryViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "boundary";
const log = boundaryViolation ? options.rt.logger.warn.bind(options.rt.logger) : options.rt.logger.error.bind(options.rt.logger);
const limitViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "limit";
const log = boundaryViolation || limitViolation
? options.rt.logger.warn.bind(options.rt.logger)
: options.rt.logger.error.bind(options.rt.logger);
log(
{
runId: options.runId,
@@ -61,12 +68,20 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
},
boundaryViolation
? "Agent file delivery refused by workspace boundary"
: "Agent file delivery failed during workspace file access",
: limitViolation
? "Agent file delivery refused by file size limit"
: "Agent file delivery failed during workspace file access",
);
if (limitViolation) {
return {
isError: true,
content: [{ type: "text", text: `File exceeds the configured delivery limit: ${args.path}` }],
};
}
if (!boundaryViolation) throw error;
return {
isError: true,
content: [{ type: "text", text: "File path is outside the current workspace or uses a symlink." }],
content: [{ type: "text", text: "File path is outside the current workspace, belongs to platform runtime, or uses a symlink." }],
};
}
if (file === null) {
+8 -1
View File
@@ -37,6 +37,7 @@ import { createPermissionAuthorizer, type AuthorizationDecision, type Permission
import { writeAudit } from "../audit.js";
import { formatRunCostLine } from "../agent/cost.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { CURATED_SKILL_IDS } from "../agent/curatedSkills.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
@@ -405,6 +406,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
metadata: {
roleId,
model,
requestedSkills: [...CURATED_SKILL_IDS],
prompt: agentPrompt.slice(0, 200),
sender: senderMetadata,
feishuTriggerContext,
@@ -444,6 +446,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
maxFileBytes: deps.resourceLimits?.maxBytesPerFile,
sendOptions,
approvalManager,
tools: cphHubMcpToolsForRole(roleTools),
@@ -567,7 +570,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id,
projectId,
action: "run.finished",
metadata: { status: result.status, deliveredFiles },
metadata: {
status: result.status,
deliveredFiles,
initializedSkills: [...(result.initializedSkillIds ?? [])],
},
});
await removeProcessingReaction();
})
+12 -2
View File
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, rm } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
@@ -595,7 +595,11 @@ function projectWorkspaceDir(input: {
readonly projectId: string;
}): string {
const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root"));
const dir = resolve(root, safePathSegment(input.organizationSlug, "organization slug"), safePathSegment(input.projectId, "project id"));
const dir = resolve(
root,
compactWorkspaceSegment("o", input.organizationSlug, "organization slug"),
compactWorkspaceSegment("p", input.projectId, "project id"),
);
const rel = relative(root, dir);
if (rel === "" || rel.startsWith("..")) {
throw new Error(`allocated workspace escapes root: ${dir}`);
@@ -603,6 +607,12 @@ function projectWorkspaceDir(input: {
return dir;
}
function compactWorkspaceSegment(prefix: "o" | "p", value: string, label: string): string {
const normalized = safePathSegment(value, label);
const digest = createHash("sha256").update(normalized).digest("base64url").slice(0, 16);
return `${prefix}_${digest}`;
}
function safePathSegment(value: string, label: string): string {
const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_");
if (segment === "." || segment === ".." || segment === "") {
+36 -1
View File
@@ -37,6 +37,7 @@ export async function readWorkspaceFileNoFollow(
workspaceRoot: string,
workspaceDir: string,
requestedPath: string,
maxBytes?: number,
): Promise<WorkspaceFileSnapshot> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath);
@@ -53,8 +54,15 @@ export async function readWorkspaceFileNoFollow(
if (!metadata.isFile()) {
throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath);
}
if (maxBytes !== undefined && metadata.size > maxBytes) {
throw new WorkspaceFileBoundaryError(
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
const data = await file.readFile();
const data = await readFileSnapshot(file, maxBytes, requestedPath);
result = { path: join(workspace, ...components), name, data };
} catch (error) {
failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks");
@@ -67,6 +75,33 @@ export async function readWorkspaceFileNoFollow(
return result!;
}
async function readFileSnapshot(
file: FileHandle,
maxBytes: number | undefined,
requestedPath: string,
): Promise<Buffer> {
if (maxBytes === undefined) return file.readFile();
const chunks: Buffer[] = [];
let total = 0;
let position = 0;
while (true) {
const remainingWithSentinel = maxBytes - total + 1;
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, remainingWithSentinel));
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
if (bytesRead === 0) return Buffer.concat(chunks, total);
total += bytesRead;
if (total > maxBytes) {
throw new WorkspaceFileBoundaryError(
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
chunks.push(chunk.subarray(0, bytesRead));
position += bytesRead;
}
}
/**
* Create one inbound file exclusively below the workspace and stream into its
* already-open descriptor. Linux uses /proc/self/fd-relative traversal so a
@@ -36,22 +36,22 @@ describe("real Claude SDK sandbox boundary", () => {
const cphBin = cphPathOutput.trim();
await access(cphBin, constants.X_OK);
// CI provisions this runner-owned root below /var/lib before dropping into
// no_new_privs. Keeping the fixture out of /tmp proves that an SDK-wide
// temp exception cannot make a cross-project escape look contained.
// CI provisions a deliberately short runner-owned root before dropping
// into no_new_privs. Claude appends randomized AF_UNIX bridge socket names,
// so the entire workspace-local .cph/t prefix must stay within its budget.
const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim();
if (configuredTestRoot === undefined || configuredTestRoot === "") {
throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof");
}
const root = await realpath(configuredTestRoot);
if (!root.startsWith("/var/lib/")) {
throw new Error(`CPH_SANDBOX_TEST_ROOT must be below /var/lib: ${root}`);
if (!root.startsWith("/") || Buffer.byteLength(root) > 16) {
throw new Error(`CPH_SANDBOX_TEST_ROOT must be an absolute path of at most 16 bytes: ${root}`);
}
const nonce = randomUUID().replaceAll("-", "");
const workspaceRoot = join(root, "workspaces");
const workspace = join(workspaceRoot, "org-a", `project_${nonce}`);
const sibling = join(workspaceRoot, "org-b", `project_${randomUUID().replaceAll("-", "")}`);
const serviceSecret = join(root, `service-secret-${nonce}`);
const workspaceRoot = join(root, "w");
const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`);
roots.push(workspace, sibling, serviceSecret);
await Promise.all([
mkdir(workspace, { recursive: true }),
@@ -62,6 +62,9 @@ describe("real Claude SDK sandbox boundary", () => {
writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
writeFile(serviceSecret, "platform-secret\n"),
]);
const untrustedSkill = join(workspace, ".claude", "skills", "untrusted");
await mkdir(untrustedSkill, { recursive: true });
await writeFile(join(untrustedSkill, "SKILL.md"), "---\nname: untrusted\ndescription: must never load\n---\n");
// macOS tmpdir is reached through /var -> /private/var. Exercise the
// sandbox with canonical paths, matching the canonical cwd returned by
// createAgentSecurityPolicy rather than relying on a host symlink alias.
@@ -70,8 +73,7 @@ describe("real Claude SDK sandbox boundary", () => {
const canonicalServiceSecret = await realpath(serviceSecret);
const resultPath = join(canonicalWorkspace, "sandbox-result.txt");
const cphVersionPath = join(canonicalWorkspace, "cph-version.txt");
const effectiveTempProbePath = join(canonicalWorkspace, ".cph", "t", "effective-temp.txt");
const denialLogPath = join(canonicalWorkspace, ".cph", "t", "denials.log");
const denialLogPath = join(canonicalWorkspace, ".cph", "denials.log");
const siblingEscapePath = join(canonicalSibling, "escape.txt");
const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`);
const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`;
@@ -88,21 +90,21 @@ describe("real Claude SDK sandbox boundary", () => {
const bashCommand = [
"set -eu",
`if printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 31; fi`,
`printf 'ephemeral\\n' > ${shellQuote(unsandboxedEscapePath)}`,
`test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`,
`if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); then exit 21; fi`,
`if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 32; fi`,
`printf 'ephemeral\\n' > ${shellQuote(siblingEscapePath)}`,
`if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`,
`mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`test "\${TMPDIR-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
`test "\${TMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
`test "\${TEMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
'test "${CLAUDE_CODE_TMPDIR-unset}" = ".cph/t"',
`test "$(realpath "$TMPDIR")" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
`printf 'workspace-temp\\n' > "$TMPDIR/effective-temp.txt"`,
`if printf 'host-temp\\n' > ${shellQuote(hostTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 33; fi`,
`if printf 'host-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; fi`,
'test "${TMPDIR-unset}" = "${TMP-unset}"',
'test "${TMPDIR-unset}" = "${TEMP-unset}"',
'test "${TMPDIR-unset}" = "${CLAUDE_CODE_TMPDIR-unset}"',
'test "${#TMPDIR}" -le 56',
`case "$TMPDIR" in ${shellQuote(canonicalWorkspace)}/*) :;; *) exit 35;; esac`,
`printf 'sdk-temp\\n' > "$TMPDIR/effective-temp.txt"`,
`printf 'ephemeral\\n' > ${shellQuote(hostTmpEscapePath)}`,
`printf 'ephemeral\\n' > ${shellQuote(hostVarTmpEscapePath)}`,
'test "${DATABASE_URL-unset}" = unset',
'test "${FEISHU_APP_SECRET-unset}" = unset',
'test "${HUB_SESSION_SECRET-unset}" = unset',
@@ -146,10 +148,20 @@ describe("real Claude SDK sandbox boundary", () => {
result.status,
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(2);
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
"cph-curated:outline",
"cph-curated:lesson-project",
"cph-curated:data-processing-spec",
]));
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(1);
const sandboxedResult = toolResults[0];
expect(toolResults).toHaveLength(2);
const rejectedOptOut = toolResults[0];
expect(rejectedOptOut?.type).toBe("tool-result");
if (rejectedOptOut?.type !== "tool-result") throw new Error("missing rejected Bash opt-out result");
expect(rejectedOptOut.isError).toBe(true);
expect(rejectedOptOut.result).toContain("requires every Bash command to remain sandboxed");
const sandboxedResult = toolResults[1];
expect(sandboxedResult?.type).toBe("tool-result");
if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result");
expect(sandboxedResult.isError, `${sandboxedResult.result}\n${sdkStderr.join("")}`).toBe(false);
@@ -159,7 +171,6 @@ describe("real Claude SDK sandbox boundary", () => {
});
expect(resultContents).toBe("sandbox-ok\n");
await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /);
await expect(readFile(effectiveTempProbePath, "utf8")).resolves.toBe("workspace-temp\n");
const denialLog = await readFile(denialLogPath, "utf8");
expect(denialLog).not.toContain("sibling-secret");
expect(denialLog).not.toContain("platform-secret");
@@ -198,11 +209,13 @@ async function startAnthropicStub(bashCommand: string): Promise<{
};
requests++;
const events = requests === 1
// Deliberately request the SDK's bypass flag. Production sets
// allowUnsandboxedCommands=false, so the flag must be ignored and the
// host-side escape sentinels must remain absent.
// The host hook must reject the SDK's per-call sandbox bypass before
// any command starts. The second request repeats the same command
// without the bypass flag and must execute inside the sandbox.
? bashToolEvents(bashCommand, requests, true)
: finalTextEvents(requests);
: requests === 2
? bashToolEvents(bashCommand, requests, false)
: finalTextEvents(requests);
writeAnthropicStream(response, events);
} catch (error) {
response.writeHead(500, { "content-type": "application/json" });
@@ -55,6 +55,7 @@ describe("ADR-0021 project onboarding", () => {
expect(result.folderId).toBe(folder.id);
expect(result.chatId).toBeUndefined();
expect((await stat(result.workspaceDir)).isDirectory()).toBe(true);
expect(result.workspaceDir).toMatch(/\/o_[A-Za-z0-9_-]{16}\/p_[A-Za-z0-9_-]{16}$/);
const grant = await prisma.permissionGrant.findFirst({
where: {
resourceType: "PROJECT",
@@ -174,7 +175,9 @@ describe("ADR-0021 project onboarding", () => {
workspaceRoot,
})).rejects.toThrow(/forced permission settings failure/);
await expect(readdir(join(workspaceRoot, "test-default"))).resolves.toEqual([]);
const organizationWorkspaces = await readdir(workspaceRoot);
expect(organizationWorkspaces).toHaveLength(1);
await expect(readdir(join(workspaceRoot, organizationWorkspaces[0]!))).resolves.toEqual([]);
await expect(prisma.project.count()).resolves.toBe(0);
});
@@ -182,7 +185,7 @@ describe("ADR-0021 project onboarding", () => {
await seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN");
await installPermissionSettingsFailureTrigger(1);
const workspaceRoot = await tempWorkspaceRoot();
const organizationWorkspace = join(workspaceRoot, "test-default");
let organizationWorkspace: string | undefined;
const pending = createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_cleanup_failure",
@@ -195,8 +198,12 @@ describe("ADR-0021 project onboarding", () => {
);
await vi.waitFor(async () => {
const organizationWorkspaces = await readdir(workspaceRoot);
expect(organizationWorkspaces).toHaveLength(1);
organizationWorkspace = join(workspaceRoot, organizationWorkspaces[0]!);
expect(await readdir(organizationWorkspace)).toHaveLength(1);
}, { timeout: 2_000 });
if (organizationWorkspace === undefined) throw new Error("organization workspace was not allocated");
await chmod(organizationWorkspace, 0o500);
try {
const error = await outcome;
+43 -15
View File
@@ -46,7 +46,19 @@ describe("agent subprocess security policy", () => {
expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET");
expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t"));
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(policy.env.TMPDIR);
expect(policy.env.TMP).toBe(policy.env.TMPDIR);
expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56);
expect(policy.skillIds).toEqual([
"cph-curated:outline",
"cph-curated:lesson-project",
"cph-curated:data-processing-spec",
]);
expect(policy.skillPluginRoot.startsWith(canonicalWorkspace)).toBe(false);
expect(policy.sandbox.filesystem.allowRead).toContain(policy.skillPluginRoot);
expect(policy.sandbox.filesystem.allowWrite).not.toContain(policy.skillPluginRoot);
expect(policy.sandbox).toMatchObject({
enabled: true,
@@ -81,7 +93,7 @@ describe("agent subprocess security policy", () => {
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL");
});
it("keeps the short SDK socket directory inside the project workspace", async () => {
it("keeps every SDK temp variable on a short path inside the project workspace", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
workspaceRoot,
@@ -89,20 +101,36 @@ describe("agent subprocess security policy", () => {
hostEnv: { PATH: "/usr/bin:/bin" },
});
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t"));
const absoluteTemp = join(await realpath(workspace), ".cph", "t");
expect(policy.env.TMPDIR).toBe(absoluteTemp);
expect(policy.env.TMP).toBe(absoluteTemp);
expect(policy.env.TEMP).toBe(absoluteTemp);
expect(policy.sandbox.filesystem.allowWrite).toEqual([await realpath(workspace)]);
const canonicalWorkspace = await realpath(workspace);
const temp = policy.env.TMPDIR!;
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(temp);
expect(policy.env.TMP).toBe(temp);
expect(policy.env.TEMP).toBe(temp);
expect(temp).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(temp)).toBeLessThanOrEqual(56);
expect(policy.sandbox.filesystem.allowWrite).toEqual([canonicalWorkspace]);
expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]);
expect(policy.sandbox.filesystem.allowRead).not.toContain(expect.stringMatching(/^\/run\//));
expect(policy.sandbox.filesystem.allowRead).toContain(canonicalWorkspace);
});
it("fails before spawning Claude when the workspace makes bridge socket paths unsafe", async () => {
const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "hub-agent-long-"));
roots.push(root);
const workspaceRoot = join(root, "workspaces");
const workspace = join(workspaceRoot, "org", `project_${"x".repeat(80)}`);
await mkdir(workspace, { recursive: true });
await expect(createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" },
})).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets");
});
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside");
const linked = join(workspaceRoot, "org", "linked-project");
const linked = join(workspaceRoot, "o", "linked-project");
await mkdir(outside);
await symlink(outside, linked);
@@ -116,8 +144,8 @@ describe("agent subprocess security policy", () => {
it("rejects a project workspace symlink whose target is a sibling under the same root", async () => {
const { workspaceRoot } = await makeWorkspace();
const sibling = join(workspaceRoot, "org", "sibling-project");
const linked = join(workspaceRoot, "org", "linked-project");
const sibling = join(workspaceRoot, "o", "sibling-project");
const linked = join(workspaceRoot, "o", "linked-project");
await mkdir(sibling);
await symlink(sibling, linked);
@@ -130,10 +158,10 @@ describe("agent subprocess security policy", () => {
});
async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> {
const root = await mkdtemp(join(tmpdir(), "hub-agent-security-"));
const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "h-"));
roots.push(root);
const workspaceRoot = join(root, "workspaces");
const workspace = join(workspaceRoot, "org", "project");
const workspaceRoot = join(root, "w");
const workspace = join(workspaceRoot, "o", "p");
await mkdir(workspace, { recursive: true });
return { root, workspaceRoot, workspace };
}
+71
View File
@@ -0,0 +1,71 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
CURATED_SKILL_IDS,
CURATED_SKILL_NAMES,
CURATED_SKILL_PLUGIN_NAME,
validateCuratedSkillPlugin,
} from "../../src/agent/curatedSkills.js";
describe("validateCuratedSkillPlugin", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("accepts exactly the release-owned plugin and returns qualified skill ids", async () => {
const root = await skillPluginFixture();
await expect(validateCuratedSkillPlugin(root)).resolves.toEqual({
root,
skillIds: CURATED_SKILL_IDS,
});
});
it("fails closed when a curated skill is absent from the release", async () => {
const root = await skillPluginFixture();
await rm(join(root, "skills", "outline"), { recursive: true });
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated plugin entry missing: skills\/outline/);
});
it("fails closed when a skill manifest name does not match the allowlist", async () => {
const root = await skillPluginFixture();
await writeFile(join(root, "skills", "outline", "SKILL.md"), "---\nname: other\n---\n");
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated skill manifest name mismatch/);
});
it("rejects an extra skill directory", async () => {
const root = await skillPluginFixture();
await mkdir(join(root, "skills", "extra"));
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: skills\/extra/);
});
it("rejects plugin capabilities outside the reviewed skill catalog", async () => {
const root = await skillPluginFixture();
await mkdir(join(root, "hooks"));
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: hooks/);
});
async function skillPluginFixture(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-skills-"));
roots.push(root);
await mkdir(join(root, ".claude-plugin"), { recursive: true });
await writeFile(
join(root, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: CURATED_SKILL_PLUGIN_NAME }),
);
for (const name of CURATED_SKILL_NAMES) {
const source = join(root, "skills", name);
await mkdir(source, { recursive: true });
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`);
}
return root;
}
});
+60 -1
View File
@@ -34,6 +34,65 @@ describe("file delivery path resolution", () => {
}
});
itOnLinux("accepts arbitrary extensions and extensionless workspace files", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "lesson.json"), "{\"ok\":true}\n");
await writeFile(join(workspace, "Makefile"), "all:\n\t@true\n");
await expect(resolveDeliverableFile("lesson.json", join(root, "examples"), workspace))
.resolves.toMatchObject({ name: "lesson.json" });
await expect(resolveDeliverableFile("Makefile", join(root, "examples"), workspace))
.resolves.toMatchObject({ name: "Makefile" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("allows the Feishu inbox but refuses other platform runtime files", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await mkdir(join(workspace, ".cph", "agent-runtime"), { recursive: true });
await mkdir(join(workspace, ".cph", "inbox"), { recursive: true });
await writeFile(join(workspace, ".cph", "agent-runtime", "session.jsonl"), "internal\n");
await writeFile(join(workspace, ".cph", "denials.log"), "internal\n");
await writeFile(join(workspace, ".cph", "inbox", "source.bin"), "source\n");
await expect(
resolveDeliverableFile(".cph/inbox/source.bin", join(root, "examples"), workspace),
).resolves.toMatchObject({ name: "source.bin" });
await expect(
resolveDeliverableFile(".cph/agent-runtime/session.jsonl", join(root, "examples"), workspace),
).rejects.toMatchObject({ reason: "boundary" });
await expect(
resolveDeliverableFile(".cph/denials.log", join(root, "examples"), workspace),
).rejects.toMatchObject({ reason: "boundary" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("refuses a file larger than the configured delivery limit", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "exact.bin"), Buffer.alloc(10));
await writeFile(join(workspace, "artifact.bin"), Buffer.alloc(11));
await expect(
resolveDeliverableFile("exact.bin", join(root, "examples"), workspace, 10),
).resolves.toMatchObject({ name: "exact.bin", data: Buffer.alloc(10) });
await expect(
resolveDeliverableFile("artifact.bin", join(root, "examples"), workspace, 10),
).rejects.toMatchObject({ reason: "limit" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("rejects a deliverable symlink even when its target exists", async () => {
const root = await makeRepo();
try {
@@ -50,7 +109,7 @@ describe("file delivery path resolution", () => {
}
});
it("does not infer files from natural-language prompts", async () => {
itOnLinux("does not infer files from natural-language prompts", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
+40 -7
View File
@@ -33,6 +33,16 @@ function resultMessage(sessionId: string, costUsd?: number) {
};
}
function initMessage(skills: string[]) {
return {
type: "system",
subtype: "init",
skills,
tools: [],
plugins: [],
};
}
function messages(...items: unknown[]) {
return (async function* () {
for (const item of items) yield item;
@@ -59,9 +69,9 @@ describe("runAgent", () => {
beforeEach(async () => {
queryMock.mockReset();
root = await mkdtemp(join(tmpdir(), "hub-runner-"));
workspaceRoot = join(root, "workspaces");
workspace = join(workspaceRoot, "org", "project");
root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "r-"));
workspaceRoot = join(root, "w");
workspace = join(workspaceRoot, "o", "p");
await mkdir(workspace, { recursive: true });
workspaceRoot = await realpath(workspaceRoot);
workspace = await realpath(workspace);
@@ -102,13 +112,16 @@ describe("runAgent", () => {
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
settingSources: [],
settings: { disableBundledSkills: true },
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-curated:outline", "cph-curated:lesson-project", "cph-curated:data-processing-spec"],
strictMcpConfig: true,
sandbox: expect.objectContaining({
enabled: true,
failIfUnavailable: true,
allowUnsandboxedCommands: false,
filesystem: expect.objectContaining({
allowWrite: [workspace],
allowWrite: expect.arrayContaining([workspace]),
denyRead: ["/"],
allowRead: expect.arrayContaining([workspace]),
}),
@@ -134,6 +147,26 @@ describe("runAgent", () => {
expect(call?.options).not.toHaveProperty("resume");
});
it("returns the skills actually reported by SDK initialization", async () => {
queryMock.mockReturnValue(messages(
initMessage(["cph-curated:outline"]),
assistantMessage("fresh"),
resultMessage("sdk-session-1"),
));
const result = await runAgent({
prompt: "写一个大纲",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(result.initializedSkillIds).toEqual(["cph-curated:outline"]);
});
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
@@ -150,13 +183,13 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Read", "Bash"],
tools: ["Read", "Bash", "Skill"],
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
},
});
});
it("disables all SDK tools for an empty role tool whitelist", async () => {
it("keeps only the curated Skill dispatcher for an empty role tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
@@ -172,7 +205,7 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: [],
tools: ["Skill"],
allowedTools: [],
},
});