Compare commits

...

7 Commits

41 changed files with 2253 additions and 114 deletions
+2 -2
View File
@@ -93,8 +93,8 @@ jobs:
- name: Prove real Claude SDK Bash sandbox boundary - name: Prove real Claude SDK Bash sandbox boundary
run: | run: |
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /var/lib/cph-test sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
CPH_SANDBOX_TEST_ROOT=/var/lib/cph-test \ CPH_SANDBOX_TEST_ROOT=/w/t \
/usr/bin/setpriv --no-new-privs \ /usr/bin/setpriv --no-new-privs \
npx vitest run test/integration/agent-sandbox-linux.test.ts npx vitest run test/integration/agent-sandbox-linux.test.ts
+3
View File
@@ -28,6 +28,9 @@
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由 service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS `HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
控制面与 Docker adapter 后置(见 ADR-0025)。 控制面与 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()` its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed. emits an error and exits rather than running unsandboxed.
- `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the - `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 - `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical
current workspace and a small named system-runtime set — normal reads stay current workspace and a small named system-runtime set — normal reads stay
in the run's workspace while `/bin`, shared libraries, CA certificates, in the run's workspace while `/bin`, shared libraries, CA certificates,
fonts and the configured `cph` executable remain available as the external fonts and the configured `cph` executable remain available as the external
tool exception described above. tool exception described above.
- `sandbox.filesystem.allowWrite: [workspaceDir]` confines every write to the - `sandbox.filesystem.allowWrite: [workspaceDir]` confines persistent host
canonical ADR-0007 workspace. Config/cache/home stay beneath effects to the canonical ADR-0007 workspace. Config/cache/home stay beneath
`.cph/agent-runtime/`. `TMPDIR`, `TMP`, and `TEMP` use the absolute `.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all
workspace-local `.cph/t/` path so tools remain anchored after `cd`; point at the workspace-local `.cph/t`. The workspace allocator uses stable
`CLAUDE_CODE_TMPDIR` uses the short relative `.cph/t` prefix, resolved from compact Organization/Project path segments, deployment requires a short
the canonical workspace cwd, because the SDK's socat bridge otherwise falls workspace root, and the canonical temp prefix fails fast above 56 bytes so
back to a host temp directory when its Unix-socket prefix is too long. the SDK can append randomized `socat` bridge socket names without exceeding
Root is denied for writes and only the canonical workspace is re-opened, so Linux `sockaddr_un.sun_path`. Bubblewrap shadows non-allowlisted host trees
`/tmp`, `/var/tmp`, sibling projects and every other host path are rejected. with disposable tmpfs mounts: a shell write there may succeed inside that
There is no writable scratch exception outside the project directory. 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`. - The SDK subprocess environment replaces rather than spreads `process.env`.
Only provider protocol variables and non-secret runtime variables cross the Only provider protocol variables and non-secret runtime variables cross the
boundary; database, Feishu and Hub session credentials never enter it. boundary; database, Feishu and Hub session credentials never enter it.
@@ -118,6 +122,14 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
- `settingSources: []` and strict MCP configuration prevent an untrusted - `settingSources: []` and strict MCP configuration prevent an untrusted
workspace or service-user config from widening tools, hooks, MCP servers, or workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths. 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. 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). - Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the `bypassPermissions` is kept (headless server — no interactive prompts); the
+81
View File
@@ -0,0 +1,81 @@
# para-26071100 飞书应用配置清单
本文供 `para-26071100` 的飞书企业管理员操作。不要把 App Secret 粘贴到群聊、工单或本文档中;请通过约定的安全渠道交给平台部署人员。
## 1. 创建企业自建应用
1. 打开飞书开放平台开发者后台。
2. 在目标企业下创建“企业自建应用”。
3. 应用名称可填写 `Educraft para-26071100`
4. 在“凭证与基础信息”记录:
- App ID(通常以 `cli_` 开头)
- App Secret
5. 添加并启用“机器人”能力。
Bot Open ID 不需要管理员手工寻找。平台部署人员会使用 App ID/App Secret 调用飞书 Bot Info API 获取,并在 bootstrap 时校验它确实属于这一个应用。
## 2. 开通权限
在“权限管理”中搜索并申请下列能力。飞书控制台的中文名称可能随版本调整;如控制台同时显示 scope,可优先核对括号中的 scope。
- 接收群聊中 @ 机器人的消息(`im:message.group_at_msg:readonly`
- 以应用身份发送消息(`im:message:send_as_bot`
- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`
- 获取与上传图片或文件资源(`im:resource`
- 添加、删除消息表情回复(`im:message.reactions:write_only`
- 获取用户基本信息(`contact:user.base:readonly``contact:user.basic_profile:readonly`
如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限。
## 3. 配置事件与卡片回调
1. 进入“事件与回调”。
2. 订阅方式选择“使用长连接接收事件”。
3. 添加事件 `im.message.receive_v1`(接收消息)。
4. 启用卡片交互回调 `card.action.trigger`,用于审批、中断运行和群聊建项目按钮。
5. 不需要填写公网 Event Callback URLHub 使用飞书长连接。
## 4. 配置 OAuth 回调
域名 DNS 和 TLS 生效后,在安全设置/重定向 URL 中添加:
```text
https://para-26071100.educraft.paradigm-edu.net/auth/feishu/callback
```
该回调用于 OWNER 登录受控的 Host Console。飞书群机器人长连接本身不依赖这个 URL。
## 5. 发布并安装应用
1. 创建应用版本并提交企业管理员审核。
2. 将应用可用范围至少包含试点 OWNER 和试点群成员。
3. 发布版本。
4. 将机器人加入准备试用的飞书群。
## 6. 获取首位 OWNER 身份
平台 bootstrap 需要 OWNER 的飞书 Open ID 和显示名称。可通过飞书 API 调试台的用户信息接口查询;Open ID 通常以 `ou_` 开头。Union ID 可选,不影响首次部署。
请把以下结果通过安全渠道交给平台部署人员:
```text
Organization: para-26071100
App ID: cli_...
App Secret: (安全渠道发送)
OWNER Open ID: ou_...
OWNER 显示名称:
OWNER Union ID: (可选)
试点群名称: (可选,便于验收)
```
## 7. 验收动作
平台通知部署完成后:
1. OWNER 打开 Host Console,完成飞书 OAuth 登录。
2. 在试点群中 @机器人发送一条纯文本消息
3. 如果群尚未绑定项目,机器人应返回项目创建/绑定卡片。
4. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。
5. 再测试一个小文件附件,以及运行中断按钮。
任何一步失败时,请保留发生时间、群名、消息截图和飞书 request/log ID;不要在截图中包含 App Secret 或 Provider token。
@@ -0,0 +1,5 @@
{
"name": "cph-curated",
"description": "Reviewed curriculum-production skills shipped with the Curriculum Project Hub.",
"version": "0.0.1"
}
@@ -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 \ sudo BASE=/srv/curriculum-project-hub \
HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \ HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \
INSTANCE_ID=org-a \ INSTANCE_ID=org-a \
WORKSPACE_ROOT=/w/997 \
PORT=8788 \ PORT=8788 \
MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \ MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \
bash /srv/curriculum-project-hub/releases/<release-id>/hub/deploy/install_service.sh bash /srv/curriculum-project-hub/releases/<release-id>/hub/deploy/install_service.sh
@@ -49,7 +50,7 @@ Default state paths are:
```text ```text
/var/lib/cph-hub/org-a/home /var/lib/cph-hub/org-a/home
/var/lib/cph-hub/org-a/state /var/lib/cph-hub/org-a/state
/var/lib/cph-hub/org-a/workspaces /w/997
/var/cache/cph-hub/org-a /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_BASE optional, defaults to /srv/curriculum-project-hub
# PLATFORM_DEPLOY_RELEASE optional immutable release id, defaults to git HEAD # PLATFORM_DEPLOY_RELEASE optional immutable release id, defaults to git HEAD
# PLATFORM_DEPLOY_INSTANCE required, Silo instance id # 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_MEMORY_MAX / CPU_QUOTA / TASKS_MAX required ceilings
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz # 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 # 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="${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; } [[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid PLATFORM_DEPLOY_RELEASE" >&2; exit 1; }
INSTANCE_ID="${PLATFORM_DEPLOY_INSTANCE:?PLATFORM_DEPLOY_INSTANCE required}" 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" SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
MEMORY_MAX="${PLATFORM_DEPLOY_MEMORY_MAX:?PLATFORM_DEPLOY_MEMORY_MAX required}" MEMORY_MAX="${PLATFORM_DEPLOY_MEMORY_MAX:?PLATFORM_DEPLOY_MEMORY_MAX required}"
CPU_QUOTA="${PLATFORM_DEPLOY_CPU_QUOTA:?PLATFORM_DEPLOY_CPU_QUOTA required}" CPU_QUOTA="${PLATFORM_DEPLOY_CPU_QUOTA:?PLATFORM_DEPLOY_CPU_QUOTA required}"
@@ -67,11 +69,11 @@ fi
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" " ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
set -euo pipefail set -euo pipefail
if [ \"\$(id -u)\" = \"0\" ]; then 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 restart '$SERVICE_UNIT'
systemctl is-active --quiet '$SERVICE_UNIT' systemctl is-active --quiet '$SERVICE_UNIT'
else 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 restart '$SERVICE_UNIT'
sudo -n systemctl is-active --quiet '$SERVICE_UNIT' sudo -n systemctl is-active --quiet '$SERVICE_UNIT'
fi 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}" SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}" STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}" 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}" HOST="${HOST:-127.0.0.1}"
PORT="${PORT:?PORT is required and must be unique on the host}" PORT="${PORT:?PORT is required and must be unique on the host}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}" 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" [[ "$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%" [[ "$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" [[ "$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 KEYRING_CREATED=false
if [ -L "$KEYRING_FILE" ]; then if [ -L "$KEYRING_FILE" ]; then
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.3", "version": "0.0.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.3", "version": "0.0.8",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.3", "version": "0.0.8",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "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}`);
}
}
}
+36 -1
View File
@@ -29,7 +29,7 @@
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant. * 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 type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js"; import { claudeSdkToolConfigForRole } from "./roleTools.js";
import { createAgentSecurityPolicy } from "./security.js"; import { createAgentSecurityPolicy } from "./security.js";
@@ -91,11 +91,29 @@ export interface RunResult {
readonly costUsd?: number | undefined; readonly costUsd?: number | undefined;
readonly numTurns: number; readonly numTurns: number;
readonly sdkSessionId?: string | undefined; readonly sdkSessionId?: string | undefined;
/** Skill ids reported by the SDK init event, not merely requested options. */
readonly initializedSkillIds?: readonly string[] | undefined;
readonly error?: string; readonly error?: string;
} }
const DEFAULT_MAX_TURNS = 25; 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> { export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream; const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS; const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -115,6 +133,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let costUsd: number | undefined; let costUsd: number | undefined;
let numTurns = 0; let numTurns = 0;
let sdkSessionId: string | undefined; let sdkSessionId: string | undefined;
let initializedSkillIds: readonly string[] | undefined;
let error: string | undefined; let error: string | undefined;
try { try {
await persistAgentMessage(req, "user", req.prompt); await persistAgentMessage(req, "user", req.prompt);
@@ -150,7 +169,15 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// The project workspace is untrusted input. Do not load user/project // The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths. // settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [], settingSources: [],
plugins: [{ type: "local", path: security.skillPluginRoot, skipMcpDiscovery: true }],
skills: [...security.skillIds],
strictMcpConfig: true, 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.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model; if (req.model !== undefined) options.model = req.model;
@@ -169,6 +196,12 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
for await (const message of conversation) { for await (const message of conversation) {
switch (message.type) { switch (message.type) {
case "system": {
if (message.subtype === "init") {
initializedSkillIds = [...(message as SDKSystemMessage).skills];
}
break;
}
case "stream_event": { case "stream_event": {
const evt = (message as SDKPartialAssistantMessage).event; const evt = (message as SDKPartialAssistantMessage).event;
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") { if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
@@ -265,6 +298,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}), ...(costUsd !== undefined ? { costUsd } : {}),
numTurns, numTurns,
sdkSessionId, sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(error !== undefined ? { error } : {}), ...(error !== undefined ? { error } : {}),
}; };
} catch (e) { } catch (e) {
@@ -276,6 +310,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}), ...(costUsd !== undefined ? { costUsd } : {}),
numTurns, numTurns,
sdkSessionId, sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }), ...(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 { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path"; import { isAbsolute, join, relative, resolve } from "node:path";
import { validateCuratedSkillPlugin } from "./curatedSkills.js";
const PROVIDER_ENV_KEYS = new Set([ const PROVIDER_ENV_KEYS = new Set([
"ANTHROPIC_BASE_URL", "ANTHROPIC_BASE_URL",
@@ -26,6 +27,11 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
] as const; ] 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 { export interface AgentSecurityInput {
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly workspaceDir: string; readonly workspaceDir: string;
@@ -55,6 +61,8 @@ export interface AgentSecurityPolicy {
readonly cwd: string; readonly cwd: string;
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly env: Record<string, string | undefined>; readonly env: Record<string, string | undefined>;
readonly skillIds: readonly string[];
readonly skillPluginRoot: string;
readonly sandbox: AgentSandboxPolicy; readonly sandbox: AgentSandboxPolicy;
} }
@@ -72,10 +80,9 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]); const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]); const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]); 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 agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
const claudeCodeTmp = join(".cph", "t"); assertShortAgentTemp(agentTmp);
const skillPlugin = await validateCuratedSkillPlugin();
const path = hostEnv["PATH"]?.trim(); const path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") { if (path === undefined || path === "") {
@@ -91,14 +98,13 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
env.XDG_CACHE_HOME = agentCache; env.XDG_CACHE_HOME = agentCache;
env.XDG_CONFIG_HOME = agentConfig; env.XDG_CONFIG_HOME = agentConfig;
env.XDG_STATE_HOME = agentState; env.XDG_STATE_HOME = agentState;
// General subprocess temp paths stay absolute so tools continue to work // All four variables must use the short path. Claude's sandbox bridge uses
// after `cd`. Only the SDK's socket prefix is relative: Claude resolves it // the ordinary temp variables, while other SDK paths use CLAUDE_CODE_TMPDIR.
// from the canonical workspace cwd before Bash commands can change cwd.
env.TMPDIR = agentTmp; env.TMPDIR = agentTmp;
env.TMP = agentTmp; env.TMP = agentTmp;
env.TEMP = agentTmp; env.TEMP = agentTmp;
env.CLAUDE_CONFIG_DIR = agentConfig; env.CLAUDE_CONFIG_DIR = agentConfig;
env.CLAUDE_CODE_TMPDIR = claudeCodeTmp; env.CLAUDE_CODE_TMPDIR = agentTmp;
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
env.DISABLE_TELEMETRY = "1"; env.DISABLE_TELEMETRY = "1";
env.DO_NOT_TRACK = "1"; env.DO_NOT_TRACK = "1";
@@ -117,6 +123,8 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
cwd: workspaceDir, cwd: workspaceDir,
workspaceRoot, workspaceRoot,
env, env,
skillIds: skillPlugin.skillIds,
skillPluginRoot: skillPlugin.root,
sandbox: { sandbox: {
enabled: true, enabled: true,
failIfUnavailable: true, failIfUnavailable: true,
@@ -132,7 +140,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
// workspace plus the named system runtime needed to execute tools. // workspace plus the named system runtime needed to execute tools.
// SDK allowRead takes precedence over matching denyRead paths. // SDK allowRead takes precedence over matching denyRead paths.
denyRead: ["/"], denyRead: ["/"],
allowRead: [workspaceDir, ...runtimeReadPaths], allowRead: [workspaceDir, skillPlugin.root, ...runtimeReadPaths],
}, },
credentials: { credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })), 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[] { function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const platformPaths = process.platform === "darwin" const platformPaths = process.platform === "darwin"
? [ ? [
@@ -282,7 +299,7 @@ async function ensureDirectoryTree(root: string, components: readonly string[]):
} }
const canonical = await realpath(current); const canonical = await realpath(current);
if (canonical !== current || !isStrictDescendant(root, canonical)) { 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); await chmod(canonical, 0o700);
return canonical; return canonical;
+58
View File
@@ -49,6 +49,7 @@ export async function bootstrapAlphaSilo(
} = {}, } = {},
): Promise<AlphaSiloBootstrapResult> { ): Promise<AlphaSiloBootstrapResult> {
const normalized = validateInput(input); const normalized = validateInput(input);
await removePristineLegacyMigrationOrganization(prisma);
const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId); const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId);
let initialized = false; let initialized = false;
let ownerUserId: string; let ownerUserId: string;
@@ -113,6 +114,63 @@ export async function bootstrapAlphaSilo(
}; };
} }
const LEGACY_MIGRATION_ORGANIZATION = {
id: "org_default",
slug: "legacy-default",
name: "Legacy Default Organization",
inboxId: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa",
} as const;
/**
* A fresh database still receives the compatibility Organization inserted by
* the tenant-root migration, plus its later default settings and Inbox. An
* Alpha Silo bootstrap may replace only that exact, otherwise-unused scaffold.
* Any tenant data or shape drift remains a hard manual-repair error.
*/
async function removePristineLegacyMigrationOrganization(prisma: PrismaClient): Promise<void> {
await prisma.$transaction(async (tx) => {
const organization = await tx.organization.findUnique({
where: { id: LEGACY_MIGRATION_ORGANIZATION.id },
include: {
memberships: { take: 1, select: { id: true } },
projects: { take: 1, select: { id: true } },
teams: { take: 1, select: { id: true } },
externalDirectoryConnections: { take: 1, select: { id: true } },
providerConnections: { take: 1, select: { id: true } },
feishuApplicationConnection: { select: { id: true } },
auditEntries: { take: 1, select: { id: true } },
projectSettings: true,
folders: {
take: 2,
select: { id: true, parentId: true, name: true, sortKey: true, archivedAt: true },
},
},
});
if (organization === null) return;
const pristine =
organization.slug === LEGACY_MIGRATION_ORGANIZATION.slug &&
organization.name === LEGACY_MIGRATION_ORGANIZATION.name &&
organization.status === "ACTIVE" &&
organization.memberships.length === 0 &&
organization.projects.length === 0 &&
organization.teams.length === 0 &&
organization.externalDirectoryConnections.length === 0 &&
organization.providerConnections.length === 0 &&
organization.feishuApplicationConnection === null &&
organization.auditEntries.length === 0 &&
organization.projectSettings?.membersCanCreateProjects === true &&
organization.folders.length === 1 &&
organization.folders[0]?.id === LEGACY_MIGRATION_ORGANIZATION.inboxId &&
organization.folders[0].parentId === null &&
organization.folders[0].name === "Inbox" &&
organization.folders[0].sortKey === "000000" &&
organization.folders[0].archivedAt === null;
if (pristine) {
await tx.organization.delete({ where: { id: organization.id } });
}
});
}
async function ensureBootstrapTeams( async function ensureBootstrapTeams(
prisma: PrismaClient, prisma: PrismaClient,
organizationId: string, organizationId: string,
+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 { import {
WorkspaceFileBoundaryError, WorkspaceFileBoundaryError,
readWorkspaceFileNoFollow, readWorkspaceFileNoFollow,
@@ -10,39 +10,21 @@ export interface DeliverableFile {
readonly data: Buffer; readonly data: Buffer;
} }
const DELIVERABLE_EXTENSIONS = new Set([ const DELIVERABLE_CPH_DIRECTORY = "inbox";
".csv",
".doc",
".docx",
".gif",
".jpeg",
".jpg",
".md",
".pdf",
".png",
".ppt",
".pptx",
".svg",
".txt",
".typ",
".xls",
".xlsx",
".zip",
]);
export async function resolveDeliverableFile( export async function resolveDeliverableFile(
requestedPath: string, requestedPath: string,
workspaceRoot: string, workspaceRoot: string,
workspaceDir: string, workspaceDir: string,
maxBytes?: number,
): Promise<DeliverableFile | null> { ): Promise<DeliverableFile | null> {
const token = requestedPath.trim(); const token = requestedPath.trim();
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) { if (token === "") return null;
return null;
}
for (const candidate of candidatePaths(token)) { for (const candidate of candidatePaths(token)) {
assertNotPlatformRuntimePath(candidate, workspaceDir);
try { try {
return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate); return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate, maxBytes);
} catch (error) { } catch (error) {
if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") { if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") {
throw error; throw error;
@@ -54,6 +36,20 @@ export async function resolveDeliverableFile(
return null; 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[] { function candidatePaths(token: string): string[] {
if (isAbsolute(token)) return [token]; if (isAbsolute(token)) return [token];
const candidates = [token]; const candidates = [token];
+20 -5
View File
@@ -15,6 +15,7 @@ export interface FileDeliveryToolOptions {
readonly runId: string; readonly runId: string;
readonly workspaceRoot?: string | undefined; readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string; readonly workspaceDir: string;
readonly maxFileBytes?: number | undefined;
readonly sendOptions?: SendMessageOptions | undefined; readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager; readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void; readonly onDelivered?: (path: string) => void;
@@ -29,7 +30,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push( tools.push(
tool( tool(
"send_file", "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."), 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."), 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." }], 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; let file;
try { try {
file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir); file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir, options.maxFileBytes);
} catch (error) { } catch (error) {
const boundaryViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "boundary"; 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( log(
{ {
runId: options.runId, runId: options.runId,
@@ -61,12 +68,20 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
}, },
boundaryViolation boundaryViolation
? "Agent file delivery refused by workspace boundary" ? "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; if (!boundaryViolation) throw error;
return { return {
isError: true, 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) { if (file === null) {
+8 -1
View File
@@ -37,6 +37,7 @@ import { createPermissionAuthorizer, type AuthorizationDecision, type Permission
import { writeAudit } from "../audit.js"; import { writeAudit } from "../audit.js";
import { formatRunCostLine } from "../agent/cost.js"; import { formatRunCostLine } from "../agent/cost.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js"; import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { CURATED_SKILL_IDS } from "../agent/curatedSkills.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js"; import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js"; import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
@@ -405,6 +406,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
metadata: { metadata: {
roleId, roleId,
model, model,
requestedSkills: [...CURATED_SKILL_IDS],
prompt: agentPrompt.slice(0, 200), prompt: agentPrompt.slice(0, 200),
sender: senderMetadata, sender: senderMetadata,
feishuTriggerContext, feishuTriggerContext,
@@ -444,6 +446,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id, runId: run.id,
workspaceRoot: projectWorkspaceRoot, workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir, workspaceDir: project.workspaceDir,
maxFileBytes: deps.resourceLimits?.maxBytesPerFile,
sendOptions, sendOptions,
approvalManager, approvalManager,
tools: cphHubMcpToolsForRole(roleTools), tools: cphHubMcpToolsForRole(roleTools),
@@ -567,7 +570,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id, runId: run.id,
projectId, projectId,
action: "run.finished", action: "run.finished",
metadata: { status: result.status, deliveredFiles }, metadata: {
status: result.status,
deliveredFiles,
initializedSkills: [...(result.initializedSkillIds ?? [])],
},
}); });
await removeProcessingReaction(); 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 { mkdir, rm } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path"; import { dirname, relative, resolve } from "node:path";
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client"; import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
@@ -595,7 +595,11 @@ function projectWorkspaceDir(input: {
readonly projectId: string; readonly projectId: string;
}): string { }): string {
const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root")); 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); const rel = relative(root, dir);
if (rel === "" || rel.startsWith("..")) { if (rel === "" || rel.startsWith("..")) {
throw new Error(`allocated workspace escapes root: ${dir}`); throw new Error(`allocated workspace escapes root: ${dir}`);
@@ -603,6 +607,12 @@ function projectWorkspaceDir(input: {
return dir; 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 { function safePathSegment(value: string, label: string): string {
const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_"); const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_");
if (segment === "." || segment === ".." || segment === "") { if (segment === "." || segment === ".." || segment === "") {
+36 -1
View File
@@ -37,6 +37,7 @@ export async function readWorkspaceFileNoFollow(
workspaceRoot: string, workspaceRoot: string,
workspaceDir: string, workspaceDir: string,
requestedPath: string, requestedPath: string,
maxBytes?: number,
): Promise<WorkspaceFileSnapshot> { ): Promise<WorkspaceFileSnapshot> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir); const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath); const components = fileComponents(workspace, requestedPath);
@@ -53,8 +54,15 @@ export async function readWorkspaceFileNoFollow(
if (!metadata.isFile()) { if (!metadata.isFile()) {
throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath); 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); 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 }; result = { path: join(workspace, ...components), name, data };
} catch (error) { } catch (error) {
failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks"); failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks");
@@ -67,6 +75,33 @@ export async function readWorkspaceFileNoFollow(
return result!; 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 * Create one inbound file exclusively below the workspace and stream into its
* already-open descriptor. Linux uses /proc/self/fd-relative traversal so a * 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(); const cphBin = cphPathOutput.trim();
await access(cphBin, constants.X_OK); await access(cphBin, constants.X_OK);
// CI provisions this runner-owned root below /var/lib before dropping into // CI provisions a deliberately short runner-owned root before dropping
// no_new_privs. Keeping the fixture out of /tmp proves that an SDK-wide // into no_new_privs. Claude appends randomized AF_UNIX bridge socket names,
// temp exception cannot make a cross-project escape look contained. // so the entire workspace-local .cph/t prefix must stay within its budget.
const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim(); const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim();
if (configuredTestRoot === undefined || configuredTestRoot === "") { if (configuredTestRoot === undefined || configuredTestRoot === "") {
throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof"); throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof");
} }
const root = await realpath(configuredTestRoot); const root = await realpath(configuredTestRoot);
if (!root.startsWith("/var/lib/")) { if (!root.startsWith("/") || Buffer.byteLength(root) > 16) {
throw new Error(`CPH_SANDBOX_TEST_ROOT must be below /var/lib: ${root}`); throw new Error(`CPH_SANDBOX_TEST_ROOT must be an absolute path of at most 16 bytes: ${root}`);
} }
const nonce = randomUUID().replaceAll("-", ""); const nonce = randomUUID().replaceAll("-", "");
const workspaceRoot = join(root, "workspaces"); const workspaceRoot = join(root, "w");
const workspace = join(workspaceRoot, "org-a", `project_${nonce}`); const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
const sibling = join(workspaceRoot, "org-b", `project_${randomUUID().replaceAll("-", "")}`); const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
const serviceSecret = join(root, `service-secret-${nonce}`); const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`);
roots.push(workspace, sibling, serviceSecret); roots.push(workspace, sibling, serviceSecret);
await Promise.all([ await Promise.all([
mkdir(workspace, { recursive: true }), mkdir(workspace, { recursive: true }),
@@ -62,6 +62,9 @@ describe("real Claude SDK sandbox boundary", () => {
writeFile(join(sibling, "secret.txt"), "sibling-secret\n"), writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
writeFile(serviceSecret, "platform-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 // macOS tmpdir is reached through /var -> /private/var. Exercise the
// sandbox with canonical paths, matching the canonical cwd returned by // sandbox with canonical paths, matching the canonical cwd returned by
// createAgentSecurityPolicy rather than relying on a host symlink alias. // 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 canonicalServiceSecret = await realpath(serviceSecret);
const resultPath = join(canonicalWorkspace, "sandbox-result.txt"); const resultPath = join(canonicalWorkspace, "sandbox-result.txt");
const cphVersionPath = join(canonicalWorkspace, "cph-version.txt"); const cphVersionPath = join(canonicalWorkspace, "cph-version.txt");
const effectiveTempProbePath = join(canonicalWorkspace, ".cph", "t", "effective-temp.txt"); const denialLogPath = join(canonicalWorkspace, ".cph", "denials.log");
const denialLogPath = join(canonicalWorkspace, ".cph", "t", "denials.log");
const siblingEscapePath = join(canonicalSibling, "escape.txt"); const siblingEscapePath = join(canonicalSibling, "escape.txt");
const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`); const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`);
const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`; const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`;
@@ -88,21 +90,21 @@ describe("real Claude SDK sandbox boundary", () => {
const bashCommand = [ const bashCommand = [
"set -eu", "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"`, `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 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`, `if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`,
`mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`test "\${TMPDIR-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${TMP-unset}"',
`test "\${TMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${TEMP-unset}"',
`test "\${TEMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${CLAUDE_CODE_TMPDIR-unset}"',
'test "${CLAUDE_CODE_TMPDIR-unset}" = ".cph/t"', 'test "${#TMPDIR}" -le 56',
`test "$(realpath "$TMPDIR")" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, `case "$TMPDIR" in ${shellQuote(canonicalWorkspace)}/*) :;; *) exit 35;; esac`,
`printf 'workspace-temp\\n' > "$TMPDIR/effective-temp.txt"`, `printf 'sdk-temp\\n' > "$TMPDIR/effective-temp.txt"`,
`if printf 'host-temp\\n' > ${shellQuote(hostTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 33; fi`, `printf 'ephemeral\\n' > ${shellQuote(hostTmpEscapePath)}`,
`if printf 'host-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; fi`, `printf 'ephemeral\\n' > ${shellQuote(hostVarTmpEscapePath)}`,
'test "${DATABASE_URL-unset}" = unset', 'test "${DATABASE_URL-unset}" = unset',
'test "${FEISHU_APP_SECRET-unset}" = unset', 'test "${FEISHU_APP_SECRET-unset}" = unset',
'test "${HUB_SESSION_SECRET-unset}" = unset', 'test "${HUB_SESSION_SECRET-unset}" = unset',
@@ -146,10 +148,20 @@ describe("real Claude SDK sandbox boundary", () => {
result.status, result.status,
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"), [result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
).toBe("completed"); ).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"); const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(1); expect(toolResults).toHaveLength(2);
const sandboxedResult = toolResults[0]; 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"); expect(sandboxedResult?.type).toBe("tool-result");
if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result"); if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result");
expect(sandboxedResult.isError, `${sandboxedResult.result}\n${sdkStderr.join("")}`).toBe(false); 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"); expect(resultContents).toBe("sandbox-ok\n");
await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /); await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /);
await expect(readFile(effectiveTempProbePath, "utf8")).resolves.toBe("workspace-temp\n");
const denialLog = await readFile(denialLogPath, "utf8"); const denialLog = await readFile(denialLogPath, "utf8");
expect(denialLog).not.toContain("sibling-secret"); expect(denialLog).not.toContain("sibling-secret");
expect(denialLog).not.toContain("platform-secret"); expect(denialLog).not.toContain("platform-secret");
@@ -198,11 +209,13 @@ async function startAnthropicStub(bashCommand: string): Promise<{
}; };
requests++; requests++;
const events = requests === 1 const events = requests === 1
// Deliberately request the SDK's bypass flag. Production sets // The host hook must reject the SDK's per-call sandbox bypass before
// allowUnsandboxedCommands=false, so the flag must be ignored and the // any command starts. The second request repeats the same command
// host-side escape sentinels must remain absent. // without the bypass flag and must execute inside the sandbox.
? bashToolEvents(bashCommand, requests, true) ? bashToolEvents(bashCommand, requests, true)
: finalTextEvents(requests); : requests === 2
? bashToolEvents(bashCommand, requests, false)
: finalTextEvents(requests);
writeAnthropicStream(response, events); writeAnthropicStream(response, events);
} catch (error) { } catch (error) {
response.writeHead(500, { "content-type": "application/json" }); response.writeHead(500, { "content-type": "application/json" });
@@ -55,6 +55,7 @@ describe("ADR-0021 project onboarding", () => {
expect(result.folderId).toBe(folder.id); expect(result.folderId).toBe(folder.id);
expect(result.chatId).toBeUndefined(); expect(result.chatId).toBeUndefined();
expect((await stat(result.workspaceDir)).isDirectory()).toBe(true); 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({ const grant = await prisma.permissionGrant.findFirst({
where: { where: {
resourceType: "PROJECT", resourceType: "PROJECT",
@@ -174,7 +175,9 @@ describe("ADR-0021 project onboarding", () => {
workspaceRoot, workspaceRoot,
})).rejects.toThrow(/forced permission settings failure/); })).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); 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 seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN");
await installPermissionSettingsFailureTrigger(1); await installPermissionSettingsFailureTrigger(1);
const workspaceRoot = await tempWorkspaceRoot(); const workspaceRoot = await tempWorkspaceRoot();
const organizationWorkspace = join(workspaceRoot, "test-default"); let organizationWorkspace: string | undefined;
const pending = createProjectFromOrgAdmin(prisma, { const pending = createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID, organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_cleanup_failure", actorFeishuOpenId: "ou_cleanup_failure",
@@ -195,8 +198,12 @@ describe("ADR-0021 project onboarding", () => {
); );
await vi.waitFor(async () => { await vi.waitFor(async () => {
const organizationWorkspaces = await readdir(workspaceRoot);
expect(organizationWorkspaces).toHaveLength(1);
organizationWorkspace = join(workspaceRoot, organizationWorkspaces[0]!);
expect(await readdir(organizationWorkspace)).toHaveLength(1); expect(await readdir(organizationWorkspace)).toHaveLength(1);
}, { timeout: 2_000 }); }, { timeout: 2_000 });
if (organizationWorkspace === undefined) throw new Error("organization workspace was not allocated");
await chmod(organizationWorkspace, 0o500); await chmod(organizationWorkspace, 0o500);
try { try {
const error = await outcome; const error = await outcome;
@@ -18,6 +18,21 @@ const input = {
describe("Alpha Silo bootstrap", () => { describe("Alpha Silo bootstrap", () => {
beforeEach(async () => { beforeEach(async () => {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "Organization" RESTART IDENTITY CASCADE`); await prisma.$executeRawUnsafe(`TRUNCATE TABLE "Organization" RESTART IDENTITY CASCADE`);
await prisma.organization.create({
data: {
id: "org_default",
slug: "legacy-default",
name: "Legacy Default Organization",
projectSettings: { create: { membersCanCreateProjects: true } },
folders: {
create: {
id: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa",
name: "Inbox",
sortKey: "000000",
},
},
},
});
}); });
afterAll(async () => { afterAll(async () => {
@@ -54,4 +69,21 @@ describe("Alpha Silo bootstrap", () => {
}); });
await expect(requireSiloOrganization(prisma, "org_other")).rejects.toThrow("Silo Organization mismatch"); await expect(requireSiloOrganization(prisma, "org_other")).rejects.toThrow("Silo Organization mismatch");
}); });
it("does not delete a legacy migration Organization that contains tenant data", async () => {
await prisma.project.create({
data: {
organizationId: "org_default",
folderId: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa",
name: "Legacy Project",
workspaceDir: "legacy-project",
},
});
await expect(bootstrapAlphaSilo(prisma, testSecretEnvelope, input, {
feishu: async () => {},
provider: async () => {},
})).rejects.toThrow("bootstrap database is not the configured single-Organization Silo");
await expect(prisma.organization.findUnique({ where: { id: "org_default" } })).resolves.not.toBeNull();
});
}); });
+43 -15
View File
@@ -46,7 +46,19 @@ describe("agent subprocess security policy", () => {
expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET"); expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET");
expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(policy.env.CLAUDE_CONFIG_DIR).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({ expect(policy.sandbox).toMatchObject({
enabled: true, enabled: true,
@@ -81,7 +93,7 @@ describe("agent subprocess security policy", () => {
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL"); })).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 { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({ const policy = await createAgentSecurityPolicy({
workspaceRoot, workspaceRoot,
@@ -89,20 +101,36 @@ describe("agent subprocess security policy", () => {
hostEnv: { PATH: "/usr/bin:/bin" }, hostEnv: { PATH: "/usr/bin:/bin" },
}); });
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t")); const canonicalWorkspace = await realpath(workspace);
const absoluteTemp = join(await realpath(workspace), ".cph", "t"); const temp = policy.env.TMPDIR!;
expect(policy.env.TMPDIR).toBe(absoluteTemp); expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(temp);
expect(policy.env.TMP).toBe(absoluteTemp); expect(policy.env.TMP).toBe(temp);
expect(policy.env.TEMP).toBe(absoluteTemp); expect(policy.env.TEMP).toBe(temp);
expect(policy.sandbox.filesystem.allowWrite).toEqual([await realpath(workspace)]); 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.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 () => { it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
const { root, workspaceRoot } = await makeWorkspace(); const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside"); const outside = join(root, "outside");
const linked = join(workspaceRoot, "org", "linked-project"); const linked = join(workspaceRoot, "o", "linked-project");
await mkdir(outside); await mkdir(outside);
await symlink(outside, linked); 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 () => { it("rejects a project workspace symlink whose target is a sibling under the same root", async () => {
const { workspaceRoot } = await makeWorkspace(); const { workspaceRoot } = await makeWorkspace();
const sibling = join(workspaceRoot, "org", "sibling-project"); const sibling = join(workspaceRoot, "o", "sibling-project");
const linked = join(workspaceRoot, "org", "linked-project"); const linked = join(workspaceRoot, "o", "linked-project");
await mkdir(sibling); await mkdir(sibling);
await symlink(sibling, linked); await symlink(sibling, linked);
@@ -130,10 +158,10 @@ describe("agent subprocess security policy", () => {
}); });
async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> { 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); roots.push(root);
const workspaceRoot = join(root, "workspaces"); const workspaceRoot = join(root, "w");
const workspace = join(workspaceRoot, "org", "project"); const workspace = join(workspaceRoot, "o", "p");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
return { root, workspaceRoot, workspace }; 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 () => { itOnLinux("rejects a deliverable symlink even when its target exists", async () => {
const root = await makeRepo(); const root = await makeRepo();
try { 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(); const root = await makeRepo();
try { try {
const workspace = join(root, "examples", "TH-141"); const workspace = join(root, "examples", "TH-141");
+36 -4
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[]) { function messages(...items: unknown[]) {
return (async function* () { return (async function* () {
for (const item of items) yield item; for (const item of items) yield item;
@@ -59,9 +69,9 @@ describe("runAgent", () => {
beforeEach(async () => { beforeEach(async () => {
queryMock.mockReset(); queryMock.mockReset();
root = await mkdtemp(join(tmpdir(), "hub-runner-")); root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "r-"));
workspaceRoot = join(root, "workspaces"); workspaceRoot = join(root, "w");
workspace = join(workspaceRoot, "org", "project"); workspace = join(workspaceRoot, "o", "p");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
workspaceRoot = await realpath(workspaceRoot); workspaceRoot = await realpath(workspaceRoot);
workspace = await realpath(workspace); workspace = await realpath(workspace);
@@ -102,13 +112,15 @@ describe("runAgent", () => {
permissionMode: "bypassPermissions", permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
settingSources: [], settingSources: [],
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-curated:outline", "cph-curated:lesson-project", "cph-curated:data-processing-spec"],
strictMcpConfig: true, strictMcpConfig: true,
sandbox: expect.objectContaining({ sandbox: expect.objectContaining({
enabled: true, enabled: true,
failIfUnavailable: true, failIfUnavailable: true,
allowUnsandboxedCommands: false, allowUnsandboxedCommands: false,
filesystem: expect.objectContaining({ filesystem: expect.objectContaining({
allowWrite: [workspace], allowWrite: expect.arrayContaining([workspace]),
denyRead: ["/"], denyRead: ["/"],
allowRead: expect.arrayContaining([workspace]), allowRead: expect.arrayContaining([workspace]),
}), }),
@@ -134,6 +146,26 @@ describe("runAgent", () => {
expect(call?.options).not.toHaveProperty("resume"); 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 () => { it("maps role tool ids to the Claude SDK tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));