skills(pm-dispatch): a REST request body travels through a file or a quoted heredoc, never an inline double-quoted string - #16701
Conversation
…quoted heredoc, never an inline double-quoted string The filing path that command-substituted an issue title is an agent's own REST create from Bash: the instruction surface prescribes `POST /issues` without saying how the body travels, and a JSON body built inline in a double-quoted string hands every backticked identifier in the title to the shell before the request exists. rest-channel.md gains the carriage rule and its signature on the write side; platform-readings.md gains the measured fact. Both files are at their ratchet ceiling, so both additions are fold-paid: two restatements leave rest-channel (each survives in platform-readings, which that table's own rule says holds them), and one restatement leaves platform-readings. Claude-Session: https://claude.ai/code/session_018dxq7YqsLDMeZDZ5AzsgJX Co-authored-by: Claude <noreply@anthropic.com>
|
ACCEPT — flight P, PR #16701 at head Own readings (review worktree detached at
Review checklist:
Implemented-by: os-dev subagent 维护者速读改了什么: 两份 agent 指令文件共加三行、删三行,行数不动( 为什么改: 有卡的标题里的反引号被当作命令以 root 执行了( 风险与代价(含回滚): 只动两份 markdown 指令,不发布任何包、不加门禁。回滚 = revert 一个提交。CI 里那条红的 席位意见: 复核通过,建议合并;由此衍生的读路径截断问题已另立 #16703,留给下一班。 你要做的: 手动合并本 PR(受管面,⛔ 不走队列、不挂 auto-merge);合并即关 #16661。是否合并? Generated by Claude Code |
|
CI reading (seat, on the maintainer's 「16701 红了」, 2026-09-08T00:0xZ): the one red row is Generated by Claude Code |
Fixes #16661
The filing path is confirmed, on the specimen and on six more. It is an agent's own REST create from Bash, not a workflow — so the routing stays
domain:skills, and the repair lands on the instruction surface.Routing and grading, quoted
Triage's routing (5575369751) wrote the re-judgement trigger:
The seat's grading (5576225752) fixed the shape:
Measured below: no workflow and no
scripts/**shell path in either repo puts an authored title through a shell. The trigger's second arm fires.domain:skillsstands, and this is a draft on a governed surface.The specimen: path confirmed
objectui#7562, filed by the App identity 2026-09-03T21:39:32Z. Two facts decide it.1. The body kept its attribution footer. The instruction surface records the discriminator at
platform-readings.md:348/:350: MCPissue_write createdrops the trailing attribution footer block on store; a RESTPOST /issuesbody keeps it. Read through the zero-quota page payload, #7562's stored body ends:…followed, after a blank line, by a horizontal rule (three dashes) and then the bare attribution line — the italicised "Generated by Claude Code" link to⚠️ That block is described rather than reproduced: the first version of this body quoted it literally inside the fence above, and the platform ate it on store — a footer-shaped block is stripped even from inside a code fence in a PR body. Recorded below.
claude.ai/code, with no session segment.Footer intact ⇒ created through REST, not through MCP. That alone removes the one write path the card had already excluded, and names the one that remains:
curlfrom Bash.2. The prescription existed at filing time, and says nothing about carriage.
rest-channel.md's create row —✓ 建卡带标签 POST .../issues …— landed 2026-08-24 (6d0cccc727), ten days before the specimen. It names the route and is silent on how the JSON travels. A session that builds that JSON inline in a double-quoted Bash string hands every backticked identifier in the title to the shell before the request exists; these containers run as root, which is whatuid=0(root)reports.Locating grep, re-run by this flight (objectstack
7797102139, objectui34510e0c3— both at theirorigin/main, a newer objectui tip than the seat's0203a29e):.github/workflows/**— every issue-creating step isgithub.rest.issues.create(...)insideactions/github-script(objectstackmerge-queue-triage.yml:771,platform-checklist-watchdog.yml:358,test-nightly-tiers.yml:505; objectuishadcn-check.yml:215), or objectstackpublish-smoke.yml:287gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body"whosetitleis assigned a literal string two lines above. No workflow interpolates an authored title into a shell. Neither repo usesanthropics/claude-code-action(zero files).scripts/**— no issue-creating shell path in either repo..claude/**— no helper creates an issue.settings.json:30-40allowlistscurl -sS -X POST …/issues/*/labelsand theGETreads, which is the corroborating structural fact: the fleet's sanctioned GitHub write channel iscurlfrom Bash.⇒ Verdict: agent-Bash-REST.
premise_still_valid: true.Population: six more specimens, and a false-positive class that had to be removed first
Exact-match channel, tried first and refused.
search_issues, both repos, the literal phrase quoted:The tool strips the quotes before sending, so exact-phrase matching never reaches the wire. A paren-free semantic query (
issue title contains shell output uid=0 gid=0 groups=0 root command substitution, objectui) returnedtotal_count: 0withincomplete_results: false— not a reading: the same-session control word (FilterBuilderConditionSchema omits id which the component reads at four sites) returnedtotal_count: 8with #7562 first, so the instrument is alive and simply cannot match a literal token inside a title.Scripted fallback.
list_issues,orderBy: CREATED_ATDESC,perPage: 100,fields: [number,title,created_at],since: 2026-09-01T00:00:00Z, paged to the window floor:⛔ Not exhaustive, and stated as such: the objectstack floor is 00:51:46Z, not midnight; the window is one week; and only a backticked token surrounded by separators leaves a visible hole, so the count is a lower bound by construction.
The false-positive class, and why it had to be removed. Twelve titles in that sweep carried a hole. Five of them were not corruption at all — they are intact in storage and mutilated by the MCP read path. Two independent extraction paths of the rendered page agree against the MCP read.
list_issuesreturnedi18n: spec still declares `objects.i18n: spec still declares `objects.OBJECT._views.VIEW.description` but objectui stopped resolving it …… only through anspelling``… only through an `ANY` spelling… `pkill -f ` is not covered …… `pkill -f PATTERN` is not covered …… `OS_ALLOW_MAIN_EDITS=1 ` is refused …… `OS_ALLOW_MAIN_EDITS=1 CMD` is refused …GET /api/v1/packages and /meta/package/ answer 500objectui#8185's body carries no footer ⇒ it was created through MCP, and its stored title still holds the placeholder span ⇒ the MCP write path did not eat it. This is the
platform-readings.md:218/:220class (「读侧并非一律可逆 … 被 MCP 读路径整个丢弃」), reaching titles; and it is why:349had to go (see the folds below). Without this pass the population reading would have been 12, and five of them wrong.The six that survive both channels. Each carries the hole in storage, each was created through REST (attribution footer intact), each is authored by the App identity. Recorded verbatim in fenced code so the double space survives rendering — ⛔ none repaired:
One of them reconstructs exactly, which is what makes the mechanism decisive rather than suggestive: objectstack#15511's missing token is
`gap`— its own siblings objectstack#14586 ("dashboardgaphelp text says …") and objectstack#16165 ("the es-ES and ja-JP dashboardgaphelpText leaves …") spell it.gapis not a command, so the substitution printed nothing and consumed the backticks, leaving the double space. objectui#7673 ("the dashboard zh overlay") is the same token on the same subject. A write-side sanitizer that eats angle-bracket spans cannot eat`gap`. A shell can, and does.⇒ 7 specimens (the original plus 6) in 1,750 titles over one week, both repos. The mechanism is not rare and it is not confined to one session.
The change
Two files, three insertions, three deletions, both files net zero against their ratchet ceilings.
references/rest-channel.md写侧, after the create row (+2):references/platform-readings.md, after the REST-create row (+1):All three are under the 120-byte cap.
The folds, with their survivals
rest-channel.mdas having no ratchet row. Measured, it has one —['.claude/skills/pm-dispatch/references/rest-channel.md', 82]incheck-skill-line-ratchet.mjs:642— and the file was at 82 lines, zero headroom. So both files are fold-paid, not just one. ⛔ No ceiling was raised.rest-channel.md−2. Both deletions are the exact shape that table's own rule forbids —:67「本表只指路,⛔ 不在两处各存一份」:- MCPlist_issues永不返回 assignees,这条差别本身就是走 REST 的理由。platform-readings.md:213「list_issues永不返回 assignees:fields枚举无此成员,不传也没有。」 — the fuller form, it names why. The "reason to go REST" half survives one line up atrest-channel.md:24, whose ✓ row already listsassigneesas present on the REST read.- 后者可瞬态 404 ⇒ PR 文件读取优先走 git。platform-readings.md:137「PR 文件读取同走 git:REST PR files 端点可瞬态 404。」 — the same fact and the same prescription, naming the endpoint.platform-readings.md−1:- 同一路径吃掉标题里的尖括号跨度 ⇒ 标题占位写裸词(NAME / :id),⛔ 不写尖括号形。.claude/agents/os-dev.md〈字节与 sanitizer 纪律〉 —「凡要上 GitHub 的文本,尖括号形状片段一律改占位词拼写(FIELD、IDENT.MEMBER一类)。」— and in AGENTS.md's GitHub mutates body BYTES paragraph. The mechanism survives atplatform-readings.md:218「读侧并非一律可逆:行内反引号里的尖括号片段被 MCP 读路径整个丢弃,非转义,无从解码。」,:220(comments) and:224(the pre-flight check).:218/:220/:224are the correct ones, so the fold improves the file rather than merely paying for a line. The remaining half — that the read-path loss truncates a title from the first such span to its end, which:218does not say — is out of this card's scope and is recorded below.Premises, as measured
origin/maintipsscripts/**shell path creates issuesrest-channel.mdhas no ratchet rowsearch_issuescannot answer the populationGates
Derived at the final HEAD, never hand-listed:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, whose stderr names the tree —gate list derived from the tree of 'objectstack-ai/objectstack' at commit cb99940975, change set 2 paths vs merge base779710213. Every exit captured to a file before any pipe.node scripts/check-closing-keyword-parity.mjsnode scripts/check-closing-keyword-parity.mjs --self-testnode scripts/check-comment-mask-corpus.mjsnode scripts/pm/check-governed-queue-guard.mjs --self-testpnpm --filter @objectstack/lint run check:doc-formula-expressionspnpm check:agent-test-spellingpnpm check:doc-authoringpnpm check:driver-memory-censuspnpm check:nul-bytespnpm check:pm-governed-mergespnpm check:pm-skill-id-lintpnpm check:pm-skill-ratchetpnpm check:refd-timer-probepnpm check:required-contextspnpm check:skill-frame-syncpnpm check:watch-hint-literalpnpm check:pm-governed-prose(dispatch-named, not derived)The single
exit 3isPREREQUISITE NOT MET, not a finding — and it reproduced objectstack#15850 exactly: it named@objectstack/formula, and after that build landed it refused again for@objectstack/lint. Both builds ran under the shared lock (OS_VERIFY_LOCK_SLOT=issue-16661;VERDICT command-exit 0 · held the lock 118s · waited 0s, then11s), after which the gate is 0.Ratchet verdict lines, quoted from the gate's own output:
Reconciliation:
dispatch-gates --ranprints✓ dispatch-gates --ran: 16 derived famil(ies) accounted for — 16 run, 0 NOT-MEASURED., withpnpm check:pm-governed-proselisted as the one run outside the derivation.Governed classification:
node scripts/pm/check-governed-merges.mjs --teston both paths ⇒ exit 3,⛔ GOVERNED — a human merge is the review record for this PR (#9495 regime).pnpm lint— a declared narrowing, with its three evidences⓵ Population, read from
eslint.config.mjsitself, not guessed. Every rule-bearing block'sfilesglob is a TypeScript/JavaScript extension set —**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}(:971),packages/cli/src/**/*.{ts,tsx,mts,cts}(:1015),examples/**+packages/apps/**(:1054),packages/**/*.{ts,tsx,mts,cts}(:1103,:1172),**/*.{ts,tsx,mts,cts}(:1212),COMMENT_SWALLOW_FILES(:1238). No block names.md. This diff is markdown-only, so it is outside the linted population by construction.⓶ Count, read from
--format json, at final HEADcb99940975.pnpm exec eslint --no-inline-config --format jsonover the two changed paths: 2 result entries,errorCount: 0,warningCount: 2, both warnings being eslint's ownFile ignored because no matching configuration was supplied.— the tool confirming ⓵ in its own words.⓷ Invariance for untouched files. Type-aware linting is not enabled: every one of the 7 rule-bearing blocks carries
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }and nothing else — noparserOptions.project, noprojectService, no typed@typescript-eslintrules (the config says so itself at:328). No rule reads across a file boundary, so a markdown-only diff cannot move any untouched file's verdict.All three are present, so this is a measurement, not an omission. The full repo-wide
eslint . --no-inline-configrun belongs to CI.Mergeability
git fetch origin main && git merge-tree --write-tree --name-only origin/main HEADatorigin/main61b4eb3a40(it moved from the dispatch tip7797102139during the flight): exit 0, output is a bare tree oid683be9fc5f7633dccacf424a9f82c668a386ebbfwith no conflict file list. The deferred-judgement caveat does not apply here —git check-attr mergeanswersunspecifiedfor both paths, so no merge driver was routed and this is a real text merge.How this flight carried its own shell-bound strings
This card is about a string reaching a shell, so the flight held itself to the rule it is writing. Every string containing a backtick,
$(, or a$VARthat this flight passed through Bash travelled in a single-quoted heredoc or a file, never an inline double-quoted string:$(...)and$VAR, and three deletion targets carrying backticks — lives inside a<<'PYEOF'heredoc, and the script refuses unless each deletion target and each anchor matches exactly once;git commit -Ffrom a file written by a<<'MSGEOF'heredoc;<<'BODYEOF'heredoc;.sh/.mjsfiles, not from inline command strings.The one deliberate exception is the credential:
curl … -H "Authorization: Bearer $GH_TOKEN"expands a variable this flight owns, as an argv element, never inside a JSON payload. That is the distinction the new rule draws — the hazard is untrusted content inside double quotes, not a variable you meant to expand.验收备注
`objects., cut mid-token) and objectui#8185.platform-readings.md:218records the class as 「整个丢弃」 (the span is discarded); truncation-to-end is a stronger behaviour it does not state, and it silently shortens every title-based population reading. Filed separately — 承接者: the card filed by this flight (linked from the report comment); ⛔ not folded in here, because:218is at a zero-headroom ceiling and correcting it is not this card's line.维护者速读(草稿)
改了什么 — 两个 agent 指令文件各加了一条规则,共 3 行;为了不抬天花板,同时删掉 3 行重复内容(内容在别处都还在,逐条列在上面)。规则只有一句话:agent 用
curl建 GitHub issue 时,请求体必须走文件或引号定界的 heredoc,不许拼在双引号字符串里。为什么改 — 有人把 issue 标题里的反引号当成了 shell 命令来执行,而且是以 root 身份。这不是笔误:本轮在两个仓一周内的 1,750 条标题里,量到 7 条同样的痕迹(原样保留在上面,一条没改)。本车队写标题的习惯就是给标识符加反引号,所以这是一个会持续复发的口子。⛔ 要动的是引用方式,不是写作习惯 —— 让大家「别在标题里用反引号」是修错了那一侧。
风险与代价(含回滚) — 极低。改动只是两个 markdown 指令文件,不发布任何包,不动一行代码,也不新增任何门禁(门禁看不见 agent 自己的 shell)。全部 17 条门禁绿,本地试合并干净。回滚 = 直接 revert 这一个提交,没有任何下游依赖。
席位意见 —
你要做的 — 这是受管面(
.claude/**),按规矩只能人工合并:没有任何 agent 会去点 ready、入队或开自动合并。请您看一眼上面那三行新规则的措辞是否准确,然后决定:合,还是不合?(是 / 否)Generated by Claude Code