Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bf7d1c729 | |||
| 06a4dd5b7b | |||
| d19bf88503 | |||
| 11cef0bec4 |
@@ -8,7 +8,7 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "isep",
|
"name": "isep",
|
||||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(85 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、54 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(85 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、56 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Leo",
|
"name": "Leo",
|
||||||
"url": "https://uncle6.me"
|
"url": "https://uncle6.me"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "isep",
|
"name": "isep",
|
||||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(85 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、54 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(85 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、56 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||||
"version": "0.23.0",
|
"version": "0.24.0",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"inkstone",
|
"inkstone",
|
||||||
"guardrails",
|
"guardrails",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` |
|
| `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` |
|
||||||
| `commands/` | 7 支 | `/wiki-recall` `/wiki-capture` `/cp-write` `/issue-handle` … |
|
| `commands/` | 7 支 | `/wiki-recall` `/wiki-capture` `/cp-write` `/issue-handle` … |
|
||||||
| `skills/` | 2 支 | `ship-check`(東西要出去之前)/`deep-recall`(把散落的枝葉還原成一棵樹)。🔴 **這兩支的內容不一定是在這裡寫的**——誰是真相源查 `docs/file-ownership.tsv` |
|
| `skills/` | 2 支 | `ship-check`(東西要出去之前)/`deep-recall`(把散落的枝葉還原成一棵樹)。🔴 **這兩支的內容不一定是在這裡寫的**——誰是真相源查 `docs/file-ownership.tsv` |
|
||||||
| `scripts/` | 54 支 | `ticket`/`roster`/`isep-nag`/`wiki-compress`/`github-arm.sh` …(頂層檔案,不含 `lib/` 等子目錄) |
|
| `scripts/` | 56 支 | `ticket`/`roster`/`isep-nag`/`wiki-compress`/`github-arm.sh` …(頂層檔案,不含 `lib/` 等子目錄) |
|
||||||
|
|
||||||
> 🔴 這五個數字**每次都要在自己的樹上實數**,不准沿用上一版、也不准用加減推
|
> 🔴 這五個數字**每次都要在自己的樹上實數**,不准沿用上一版、也不准用加減推
|
||||||
> (`system-dev/wiki/mistakes.md`:那是 leo 的驗收介面,多報就是假綠):
|
> (`system-dev/wiki/mistakes.md`:那是 leo 的驗收介面,多報就是假綠):
|
||||||
|
|||||||
@@ -822,6 +822,43 @@ api.telegram.org → 302,通
|
|||||||
**失敗**:A1 紅 ⇒ 沒憑證時不講缺什麼,leo 不知道要在 Cloud environment 加哪兩個;
|
**失敗**:A1 紅 ⇒ 沒憑證時不講缺什麼,leo 不知道要在 Cloud environment 加哪兩個;
|
||||||
B3 紅 ⇒ 送到了還去打實例;C1 紅 ⇒ 401 被講成送到;D1 紅 ⇒ 舊版閘 block 時直送也被連坐。
|
B3 紅 ⇒ 送到了還去打實例;C1 紅 ⇒ 401 被講成送到;D1 紅 ⇒ 舊版閘 block 時直送也被連坐。
|
||||||
|
|
||||||
|
### A36 — 雲端對 stage 的寫入有正門,而且只寫得進 stage:45 條
|
||||||
|
```
|
||||||
|
bash scripts/test-stage.sh
|
||||||
|
```
|
||||||
|
**該看到**:`45/45 通過`。CF API 與 stage 實例都指到本機一個假伺服器——不打真 Cloudflare、不碰任何 worker。
|
||||||
|
假伺服器對 secret PUT 回 **201**(真 CF 就是 201)。
|
||||||
|
|
||||||
|
**它在守什麼**(inkstone/ISEP#137,2026-09-07 三條主線工人在雲端撞的同一面牆):
|
||||||
|
```
|
||||||
|
printf … | npx wrangler secret put … ← 分類器擋(複合指令對不上白名單任何一條前綴)
|
||||||
|
python3 scripts/stage-deploy-artifacts.py all --confirm ← 分類器擋
|
||||||
|
curl -X POST …arcrun-yuga3bse.workers.dev/records ← 分類器擋;0.21.0 的 prod-write-guard 又當它是 prod
|
||||||
|
```
|
||||||
|
⇒ `scripts/stage`:`secret put/list/delete`(直接打 CF API,不經 npx)與 `api <METHOD> <worker>/<path>`
|
||||||
|
(curl 打實例,標頭與 body 走 stdin,不走 argv)。**只寫 stage**:secret 先 `GET /accounts` 看這把 token
|
||||||
|
打得到誰、URL 永遠只帶 `1129efd7…`;api 的主機只會長在 `*.arcrun-yuga3bse.workers.dev`。
|
||||||
|
值只從環境變數名讀(`--from-env`/`--bearer-env`/`--header-env`/`--data-env`),`--value`/`--bearer`/`--header` 一律拒收。
|
||||||
|
Arcrun 的 `stage-deploy-artifacts.py` 自己已寫死只認 youlin 帳號 ID、不收 `--account`/`--token`,
|
||||||
|
白名單直接放它從 Arcrun 根目錄跑的相對形狀,不重造一支。
|
||||||
|
|
||||||
|
**失敗**:
|
||||||
|
- A1/B1/B2/D1/D2 紅 ⇒ **非 stage 也寫得進去**——這是本工具存在的唯一理由
|
||||||
|
- A4/D3 紅 ⇒ 值可以從指令列進來(會進 shell 歷史與 session log)
|
||||||
|
- C1 紅 ⇒ 又把 201 講成失敗(第一次實跑就是這樣:CF 種好了、工具說失敗)
|
||||||
|
- C7/C9 紅 ⇒ 只信 PUT 的回應不去列
|
||||||
|
- **E2 紅 ⇒ ISEP 自己的閘誤攔正門**——誤攔比漏擋嚴重,被擋的人會去走側門
|
||||||
|
- E4 紅 ⇒ 又長出第二支平行的工具
|
||||||
|
|
||||||
|
**真實跑(2026-09-07,雲端 session,isep-hand)**:
|
||||||
|
`stage secret put` 把隨機值的 `ISEP_137_PROBE` 種進 youlin `arcrun-cypher-executor` → 不經工具用 CF API 列到
|
||||||
|
`["ISEP_137_PROBE","KBDB_INTERNAL_TOKEN"]` → `stage secret delete` → 再列剩 `["KBDB_INTERNAL_TOKEN"]`(stage 恢復原狀)。
|
||||||
|
`stage api GET arcrun-cypher-executor/health` → HTTP 200;`stage api POST arcrun-kbdb/map/recompute…` 不帶 Bearer → HTTP 401、離開碼 1(打到了、沒寫);
|
||||||
|
完整網址指到 leo21c → 拒絕、離開碼 2、零請求。
|
||||||
|
⚠️ **帶 Bearer 真的寫進 stage KBDB** 這格沒跑——雲端 Cloud environment 沒有 `KBDB_INTERNAL_TOKEN`(它只住在安裝器中心側 KV,Arcrun#176 comment 6487 步驟 0);
|
||||||
|
`CF_SECRETS_API_TOKEN` 的真值是主線 B-3(Arcrun#86)的東西,由總管用 `stage secret put` 種,本票不編一個假的塞進去。
|
||||||
|
⚠️ 「分類器真的不擋」與 A33 同款:要下一版裝進雲端之後,看一趟 run 的 `permission_denials`。
|
||||||
|
|
||||||
### A5 — 開票前的搜尋是跨 repo 的
|
### A5 — 開票前的搜尋是跨 repo 的
|
||||||
```
|
```
|
||||||
python3 scripts/ticket where 標籤 模組化
|
python3 scripts/ticket where 標籤 模組化
|
||||||
@@ -1104,6 +1141,7 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
|
|||||||
| **A33 權限白名單住 ISEP 一份** | isep-hand | ✅ 19/19(2026-09-07,inkstone/ISEP#130);「分類器真的不擋」要雲端 run 的 `permission_denials` 才驗得到 |
|
| **A33 權限白名單住 ISEP 一份** | isep-hand | ✅ 19/19(2026-09-07,inkstone/ISEP#130);「分類器真的不擋」要雲端 run 的 `permission_denials` 才驗得到 |
|
||||||
| **A34 收件 repo 寫 owner/repo 不 404** | isep-hand | ✅ 7/7(2026-09-07,inkstone/ISEP#130) |
|
| **A34 收件 repo 寫 owner/repo 不 404** | isep-hand | ✅ 7/7(2026-09-07,inkstone/ISEP#130) |
|
||||||
| **A35 Bot API 直送** | isep-hand | ✅ 10/10(2026-09-07);**真的到 leo 手機**要 Cloud environment 先有 `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID`(leo) |
|
| **A35 Bot API 直送** | isep-hand | ✅ 10/10(2026-09-07);**真的到 leo 手機**要 Cloud environment 先有 `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID`(leo) |
|
||||||
|
| **A36 雲端對 stage 的寫入有正門、只寫得進 stage** | isep-hand | ✅ 45/45(2026-09-07,inkstone/ISEP#137)+真實種進 youlin 一把探針再刪掉、真打 stage GET 200/無 Bearer POST 401;「帶 Bearer 真寫 KBDB」缺 `KBDB_INTERNAL_TOKEN` 沒跑 |
|
||||||
| A7 plugin 裝得起來 | 總管 | ✅ |
|
| A7 plugin 裝得起來 | 總管 | ✅ |
|
||||||
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
|
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
|
||||||
| **B1–B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment) |
|
| **B1–B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment) |
|
||||||
|
|||||||
@@ -201,9 +201,9 @@ $ curl … https://git.uncle6.me/api/v1/version → 200
|
|||||||
| 本機 daemon(`~/.arcrun-rag`、`~/.arcrun/config.yaml`)的改與驗 | 住在 leo 的家目錄 |
|
| 本機 daemon(`~/.arcrun-rag`、`~/.arcrun/config.yaml`)的改與驗 | 住在 leo 的家目錄 |
|
||||||
| 用 `~/.wrangler` OAuth 登入態出貨到 uncle6 | 登入態不隨 clone 走(`make-cloud-env.sh` 檔頭記過) |
|
| 用 `~/.wrangler` OAuth 登入態出貨到 uncle6 | 登入態不隨 clone 走(`make-cloud-env.sh` 檔頭記過) |
|
||||||
| 要 leo 親手輸入的互動閘(`acr recipe push` 的暴露同意、密碼打進登入欄) | surface 層硬規則,誰都不能代 |
|
| 要 leo 親手輸入的互動閘(`acr recipe push` 的暴露同意、密碼打進登入欄) | surface 層硬規則,誰都不能代 |
|
||||||
| 打 youlin stage 的驗證 | **暫時**:egress proxy 擋(上一段)。leo 放行主機後這條就回到雲端 |
|
| ~~打 youlin stage 的驗證~~ | 09-07 已回到雲端:`*.arcrun-yuga3bse.workers.dev` 從雲端 curl 200。**寫入走 `scripts/stage`**(secret/api,inkstone/ISEP#137),不再交回地端 |
|
||||||
|
|
||||||
其餘(讀票、認領、留言、handback、寫 code、跑測試、開 PR、對 leo21c 唯讀查詢、發 Telegram)雲端全做。
|
其餘(讀票、認領、留言、handback、寫 code、跑測試、開 PR、對 leo21c 唯讀查詢、發 Telegram、**種 secret/直推成品/POST 到 stage 實例**——走 `scripts/stage` 與 Arcrun 的 `stage-deploy-artifacts.py`)雲端全做。
|
||||||
📌 這份清單的家是 `inkstone/InkStoneCo` 的 `cloud-worker.md`(票上驗收第 4 條),ISEP 只放草稿;併進去由總管做。
|
📌 這份清單的家是 `inkstone/InkStoneCo` 的 `cloud-worker.md`(票上驗收第 4 條),ISEP 只放草稿;併進去由總管做。
|
||||||
|
|
||||||
## 這張票沒動、也不會動的東西
|
## 這張票沒動、也不會動的東西
|
||||||
|
|||||||
@@ -212,7 +212,7 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。它另外會報四件「這一份是不是還有效」:①這一份落後 ISEP main 幾版 ②專案裡有沒有內容不同的同名**腳本**②b 專案裡有沒有內容不同的同名**skill/command/agent**——這些是**自動載入**的,載到舊的那份不會有任何症狀,只會安靜地教錯的東西(`docs/file-ownership.tsv` 說得出每一組的真相源是哪一份、往哪個方向修)②c 本身的內容跟那張歸屬表對不對得上(雲端沒有專案那一份可比時,這是唯一還作數的檢查)③工作區有沒有已退役機制留下的產物。**全部只講不擋。** | 📝 記錄(context 注入) |
|
| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。它另外會報四件「這一份是不是還有效」:①這一份落後 ISEP main 幾版 ②專案裡有沒有內容不同的同名**腳本**②b 專案裡有沒有內容不同的同名**skill/command/agent**——這些是**自動載入**的,載到舊的那份不會有任何症狀,只會安靜地教錯的東西(`docs/file-ownership.tsv` 說得出每一組的真相源是哪一份、往哪個方向修)②c 本身的內容跟那張歸屬表對不對得上(雲端沒有專案那一份可比時,這是唯一還作數的檢查)③工作區有沒有已退役機制留下的產物。**全部只講不擋。** | 📝 記錄(context 注入) |
|
||||||
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
|
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
|
||||||
| `scripts/settings-allow-sync --quiet` | (不是閘,是腳本)對話一開始把 ISEP 的權限白名單(`docs/permissions-allow.json`:`ticket`/`mainline`/`gate-ok`/`gitea-pr-merge` 四個正門工具)寫進**這台機器**的 `~/.claude/settings.json`。只加不減、冪等;目標檔壞掉就不碰它、講一聲。**為什麼**:leo 09-07 親手加的四條只在本機,雲端沒有 ⇒ 同樣的動作被分類器擋(09-04 run log `permission_denials=6`);薄殼 repo 要 D20 開閘才推得動,所以清單住 ISEP 一份、兩台各自寫進自己的家目錄(inkstone/ISEP#130)。 | 📝 記錄(寫 settings) |
|
| `scripts/settings-allow-sync --quiet` | (不是閘,是腳本)對話一開始把 ISEP 的權限白名單(`docs/permissions-allow.json`:`ticket`/`mainline`/`gate-ok`/`gitea-pr-merge`/`stage`(inkstone/ISEP#137 起,雲端對 stage 寫入的正門)五個正門工具,外加 Arcrun 的 `stage-deploy-artifacts.py`)寫進**這台機器**的 `~/.claude/settings.json`。只加不減、冪等;目標檔壞掉就不碰它、講一聲。**為什麼**:leo 09-07 親手加的四條只在本機,雲端沒有 ⇒ 同樣的動作被分類器擋(09-04 run log `permission_denials=6`);薄殼 repo 要 D20 開閘才推得動,所以清單住 ISEP 一份、兩台各自寫進自己的家目錄(inkstone/ISEP#130)。 | 📝 記錄(寫 settings) |
|
||||||
| `scripts/mainline refresh` | (不是閘,是腳本)對話一開始把**現在的主線**那條 milestone 的進度與期限更新一次,好讓每回合眼前那一行講的是今天的數字。拿不到 Gitea 就原封不動——**寧可資料舊,不要把主線弄丟**。跑一次就結束,不輪詢。 | 📝 記錄 |
|
| `scripts/mainline refresh` | (不是閘,是腳本)對話一開始把**現在的主線**那條 milestone 的進度與期限更新一次,好讓每回合眼前那一行講的是今天的數字。拿不到 Gitea 就原封不動——**寧可資料舊,不要把主線弄丟**。跑一次就結束,不輪詢。 | 📝 記錄 |
|
||||||
| `overdue-nag-guard.sh` | 對話一開始就去 Gitea 撈**沒有人會叫的事**:逾期的 milestone、掛著等你的票(標「等了幾天」)、標著「有人在做」卻好幾天沒動的票;撈完用白話講出來,**有事就發 Telegram 給你**。沒東西可報時它會說「查過了,沒有」——**安靜跟壞掉長得一模一樣**。發不出去時不會靜默:它會先問這個 session 的 `prod-write-guard` 會不會擋(舊版把「發通知」誤認成「部署」),擋就改貼回票上並把原文印在眼前。只在開 session 時跑一次,**不輪詢、不掛排程**。 | 📝 記錄(context 注入 + Telegram) |
|
| `overdue-nag-guard.sh` | 對話一開始就去 Gitea 撈**沒有人會叫的事**:逾期的 milestone、掛著等你的票(標「等了幾天」)、標著「有人在做」卻好幾天沒動的票;撈完用白話講出來,**有事就發 Telegram 給你**。沒東西可報時它會說「查過了,沒有」——**安靜跟壞掉長得一模一樣**。發不出去時不會靜默:它會先問這個 session 的 `prod-write-guard` 會不會擋(舊版把「發通知」誤認成「部署」),擋就改貼回票上並把原文印在眼前。只在開 session 時跑一次,**不輪詢、不掛排程**。 | 📝 記錄(context 注入 + Telegram) |
|
||||||
| `wiki-size-guard.sh` | 對話一開始講出「哪幾個 wiki 檔已經沒有人讀得完了」(預設超過 1200 行就點名)。**讀不完的必讀檔,跟沒有那個檔的差別只在於它讓人以為有。** 另一半掛在寫檔上,見 B 組。 | 📝 記錄(context 注入) |
|
| `wiki-size-guard.sh` | 對話一開始講出「哪幾個 wiki 檔已經沒有人讀得完了」(預設超過 1200 行就點名)。**讀不完的必讀檔,跟沒有那個檔的差別只在於它讓人以為有。** 另一半掛在寫檔上,見 B 組。 | 📝 記錄(context 注入) |
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
"leo 09-07 親手把四個 Gitea 正門工具(ticket/mainline/gate-ok/gitea-pr-merge)加進本機 InkStoneCo/.claude/settings.json,auto mode 的分類器才不擋。",
|
"leo 09-07 親手把四個 Gitea 正門工具(ticket/mainline/gate-ok/gitea-pr-merge)加進本機 InkStoneCo/.claude/settings.json,auto mode 的分類器才不擋。",
|
||||||
"雲端 session 沒有那四條,同樣的動作照樣被擋(09-04 progress-guard run log:permission_denials=6)。",
|
"雲端 session 沒有那四條,同樣的動作照樣被擋(09-04 progress-guard run log:permission_denials=6)。",
|
||||||
"薄殼 repo(GitHub)要 D20 開閘才推得動 ⇒ 白名單住 ISEP,`scripts/settings-allow-sync` 開工時寫進那台機器的 ~/.claude/settings.json。",
|
"薄殼 repo(GitHub)要 D20 開閘才推得動 ⇒ 白名單住 ISEP,`scripts/settings-allow-sync` 開工時寫進那台機器的 ~/.claude/settings.json。",
|
||||||
"以後加規則只改這個檔、出一版,雲端下一趟就有。"
|
"以後加規則只改這個檔、出一版,雲端下一趟就有。",
|
||||||
|
"inkstone/ISEP#137:09-07 雲端對 stage 的三種寫入(`npx wrangler secret put`/`stage-deploy-artifacts.py`/curl POST 到 arcrun-yuga3bse)全被分類器擋——複合指令與任意 curl 對不上任何一條前綴。收成 `scripts/stage`(secret/api 兩個子命令,只寫 stage、值只從環境變數名讀)四種形狀,外加 Arcrun 自己那支 `scripts/stage-deploy-artifacts.py`(它已寫死只認 youlin 帳號 ID、不收 --account/--token)從 Arcrun repo 根目錄的相對形狀。不放 `Bash(npx *)`/`Bash(wrangler *)`/`Bash(curl *)`。"
|
||||||
],
|
],
|
||||||
"_rules": [
|
"_rules": [
|
||||||
"開頭的 `~` 由 settings-allow-sync 換成那台機器的家目錄(本機 /Users/…、雲端 /root),其餘一字不改。",
|
"開頭的 `~` 由 settings-allow-sync 換成那台機器的家目錄(本機 /Users/…、雲端 /root),其餘一字不改。",
|
||||||
"只放「正門工具」:每一條都要說得出它是哪支腳本的哪種呼叫形狀。不放 Bash(python3 *) 這種整類放行。",
|
"只放「正門工具」:每一條都要說得出它是哪支腳本的哪種呼叫形狀。不放 Bash(python3 *) 這種整類放行。",
|
||||||
"同一支工具幾種呼叫形狀就幾條(快取路徑/$CLAUDE_PLUGIN_ROOT/相對路徑)——權限規則是逐條完全比對,形狀差一個字就落在規則之外(scripts/gate-ok 檔頭記過這一課)。"
|
"同一支工具幾種呼叫形狀就幾條(快取路徑/$CLAUDE_PLUGIN_ROOT/相對路徑)——權限規則是逐條完全比對,形狀差一個字就落在規則之外(scripts/gate-ok 檔頭記過這一課)。",
|
||||||
|
"別的 repo 的正門工具也可以進來(例 Arcrun 的 `scripts/stage-deploy-artifacts.py`),條件一樣:那支腳本自己把「只打哪台」寫死在程式裡,本清單放的是它從那個 repo 根目錄跑的相對形狀。"
|
||||||
],
|
],
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/ticket *)",
|
"Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/ticket *)",
|
||||||
@@ -24,6 +26,11 @@
|
|||||||
"Bash(bash \"$CLAUDE_PLUGIN_ROOT/scripts/gate-ok\" *)",
|
"Bash(bash \"$CLAUDE_PLUGIN_ROOT/scripts/gate-ok\" *)",
|
||||||
"Bash(bash \"$CLAUDE_PLUGIN_ROOT\"/scripts/gate-ok *)",
|
"Bash(bash \"$CLAUDE_PLUGIN_ROOT\"/scripts/gate-ok *)",
|
||||||
"Bash(bash scripts/gate-ok *)",
|
"Bash(bash scripts/gate-ok *)",
|
||||||
"Bash(bash scripts/gitea-pr-merge.sh *)"
|
"Bash(bash scripts/gitea-pr-merge.sh *)",
|
||||||
|
"Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/stage *)",
|
||||||
|
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT/scripts/stage\" *)",
|
||||||
|
"Bash(python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/stage *)",
|
||||||
|
"Bash(python3 scripts/stage *)",
|
||||||
|
"Bash(python3 scripts/stage-deploy-artifacts.py *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+410
@@ -0,0 +1,410 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""stage — 雲端對 **stage(youlin)** 寫入的正門(inkstone/ISEP#137)
|
||||||
|
|
||||||
|
━━ 為什麼有這支 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
09-07 三條主線工人在雲端對 stage 的寫入全被 Claude Code auto mode 的分類器擋(不是 ISEP 的閘):
|
||||||
|
① `printf … | npx wrangler secret put CF_SECRETS_API_TOKEN --name arcrun-cypher-executor`
|
||||||
|
② `python3 scripts/stage-deploy-artifacts.py all --confirm`(Arcrun 的成品直推,CF API 上傳)
|
||||||
|
③ `curl -X POST …arcrun-yuga3bse.workers.dev/records`/`/graph/canonicalize-entities`/`/map/recompute`
|
||||||
|
權限白名單(docs/permissions-allow.json)是**逐條完全比對前綴**:複合指令(`cd … && printf … | VAR=… npx …`)
|
||||||
|
與任意寫法的 curl 永遠對不上任何一條;放整類 `Bash(npx *)`/`Bash(curl *)` 又是紅線。
|
||||||
|
leo 09-07:「為什麼會擋你,這就是要修復的問題」「本機環境跟你不同應該不行」。
|
||||||
|
|
||||||
|
⇒ 每一種寫入收成**一支固定形狀**,白名單放它的四種呼叫形狀:
|
||||||
|
`stage secret …` = ①(打 api.cloudflare.com,不經 npx——雲端每次要重抓 wrangler,
|
||||||
|
而且 wrangler 會讀 ~/.wrangler 的登入態:本機是 uncle6、雲端沒有,兩台行為不同)
|
||||||
|
`stage api …` = ③(用 curl 打 stage 實例;youlin 邊緣對 python-urllib UA 回 403 code 1010,
|
||||||
|
Arcrun#176 comment 6487——所以這一段**不用 urllib**)
|
||||||
|
② = Arcrun 自己那支已經寫死只認 youlin 帳號 ID、憑證只從
|
||||||
|
CLOUDFLARE_API_TOKEN_YOULIN_CC_USE 讀、不收 --account/--token;
|
||||||
|
白名單直接放它的形狀(`python3 scripts/stage-deploy-artifacts.py *`),不重造一支。
|
||||||
|
|
||||||
|
━━ 只寫 stage,判準是唯一識別碼往放行方向比對,不是名字 ━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
stage 隨便炸;出貨機(geek6688/uncle6)與 leo 的實例(leo21c)不是。
|
||||||
|
secret:這把 token 打得到的帳號(GET /accounts 現查)裡要有 1129efd7df2e8899d537e9c8fbabb6cb,
|
||||||
|
而且寫入的 URL 永遠只帶這個 ID。打不到 ⇒ 拒絕、印出它打得到誰、離開碼 2。
|
||||||
|
api :URL 只會長在 `<worker>.arcrun-yuga3bse.workers.dev`(09-02 重裝後的 stage 子網域)。
|
||||||
|
給完整網址時主機必須等於它,否則拒絕、離開碼 2——不管那個主機叫 leo21c 還是別的什麼。
|
||||||
|
不看變數名字裡有沒有 `leo21c`/`geek6688`——名字改了黑名單就漏,ID 與主機改不了。
|
||||||
|
|
||||||
|
━━ 值不進指令列 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
secret 的值、Bearer、任何標頭值都只從**環境變數名**讀(--from-env/--bearer-env/--header-env);
|
||||||
|
不落磁碟、不 echo、不進 git、不進 shell 歷史。curl 的標頭與 body 走 stdin 設定檔+管線,不走 argv。
|
||||||
|
|
||||||
|
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
stage secret put --worker <script> --secret <NAME> --from-env <VAR> [--token-env VAR] [--account ID]
|
||||||
|
stage secret list --worker <script>
|
||||||
|
stage secret delete --worker <script> --secret <NAME>
|
||||||
|
--token-env <VAR> CF token 的環境變數名(預設 CLOUDFLARE_API_TOKEN_YOULIN_CC_USE,雲端就有)
|
||||||
|
--account <ID> 目標帳號(預設 stage;給別的 ID 一律拒絕、不打網路)
|
||||||
|
|
||||||
|
stage api <METHOD> <worker>/<path>[?query] [--json '<body>' | --data-env <VAR> | --data-file <檔>]
|
||||||
|
[--bearer-env <VAR>] [--header-env <Name>=<VAR>]…
|
||||||
|
<worker>/<path> 例 arcrun-kbdb/records、arcrun-cypher-executor/webhooks/named/yuga3bse/x/trigger
|
||||||
|
也收完整網址,但主機必須是 *.arcrun-yuga3bse.workers.dev
|
||||||
|
--bearer-env VAR Authorization: Bearer <VAR 的值>(stage KBDB 的 POST 要 KBDB_INTERNAL_TOKEN)
|
||||||
|
--header-env N=VAR 任一標頭,值從環境變數讀(例 X-Arcrun-API-Key=ARCRUN_NS_YOULIN)
|
||||||
|
body 有給就自動帶 Content-Type: application/json
|
||||||
|
印 `HTTP <code>` + 回應;2xx 離開碼 0,其餘 1;curl 連不上印它的錯誤字串(不記成 000)
|
||||||
|
|
||||||
|
例(雲端):
|
||||||
|
python3 "$CLAUDE_PLUGIN_ROOT/scripts/stage" secret put --worker arcrun-cypher-executor \\
|
||||||
|
--secret CF_SECRETS_API_TOKEN --from-env CF_SECRETS_API_TOKEN_VALUE
|
||||||
|
python3 "$CLAUDE_PLUGIN_ROOT/scripts/stage" api POST 'arcrun-kbdb/map/recompute?library=rt-lib&owner_id=yuga3bse' \\
|
||||||
|
--bearer-env KBDB_INTERNAL_TOKEN
|
||||||
|
|
||||||
|
離開碼:0 成功 / 2 被本工具拒絕(非 stage、缺變數、值在指令列)/ 1 API・網路失敗
|
||||||
|
測試用:STAGE_SECRET_PUT_API_BASE(CF API)、STAGE_API_BASE_OVERRIDE(實例)指到假伺服器——scripts/test-stage.sh
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
STAGE_ACCOUNT_ID = "1129efd7df2e8899d537e9c8fbabb6cb" # youlin(D37 定的 stage 帳號)
|
||||||
|
STAGE_LABEL = "youlin(stage)"
|
||||||
|
STAGE_HOST_SUFFIX = ".arcrun-yuga3bse.workers.dev" # 09-02 重裝後的 stage 子網域(ISEP#130)
|
||||||
|
DEFAULT_TOKEN_ENV = "CLOUDFLARE_API_TOKEN_YOULIN_CC_USE"
|
||||||
|
CF_API_BASE = os.environ.get("STAGE_SECRET_PUT_API_BASE", "https://api.cloudflare.com/client/v4").rstrip("/")
|
||||||
|
API_BASE_OVERRIDE = os.environ.get("STAGE_API_BASE_OVERRIDE", "").rstrip("/") # 測試用:整個實例指到假伺服器
|
||||||
|
|
||||||
|
EXIT_OK, EXIT_API, EXIT_REFUSED = 0, 1, 2
|
||||||
|
FORBIDDEN_VALUE_FLAGS = ("--value", "--text", "--bearer", "--token", "--header")
|
||||||
|
|
||||||
|
|
||||||
|
def die(code, msg):
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
class Refuse(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── secret(CF API,api.cloudflare.com)─────────────────────────
|
||||||
|
|
||||||
|
def parse_secret(argv):
|
||||||
|
if not argv or argv[0] not in ("put", "list", "delete"):
|
||||||
|
raise Refuse("secret 後面要接 put/list/delete,不是 %r(--help 看用法)" % (argv[:1] or [""])[0])
|
||||||
|
cmd = argv[0]
|
||||||
|
o = {"worker": None, "secret": None, "from_env": None, "token_env": DEFAULT_TOKEN_ENV, "account": STAGE_ACCOUNT_ID}
|
||||||
|
keys = {"--worker": "worker", "--secret": "secret", "--from-env": "from_env", "--token-env": "token_env", "--account": "account"}
|
||||||
|
i = 1
|
||||||
|
while i < len(argv):
|
||||||
|
a = argv[i]
|
||||||
|
if a in FORBIDDEN_VALUE_FLAGS:
|
||||||
|
raise Refuse("本工具不收 %s:值只從環境變數名讀(--from-env/--token-env),不准出現在指令列" % a)
|
||||||
|
if a not in keys:
|
||||||
|
raise Refuse("不認得的參數:%s(--help 看用法)" % a)
|
||||||
|
if i + 1 >= len(argv):
|
||||||
|
raise Refuse("%s 後面要接一個值" % a)
|
||||||
|
o[keys[a]] = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
if not o["worker"]:
|
||||||
|
raise Refuse("缺 --worker <script 名>")
|
||||||
|
if cmd in ("put", "delete") and not o["secret"]:
|
||||||
|
raise Refuse("缺 --secret <SECRET 名>")
|
||||||
|
if cmd == "put" and not o["from_env"]:
|
||||||
|
raise Refuse("缺 --from-env <環境變數名>(值只從這裡讀)")
|
||||||
|
return cmd, o
|
||||||
|
|
||||||
|
|
||||||
|
def cf(method, path, token, body=None):
|
||||||
|
"""回 (http_status, parsed_json);網路層失敗回 (None, 錯誤字串)——不把三種病都記成 000。"""
|
||||||
|
data = None
|
||||||
|
headers = {"Authorization": "Bearer " + token, "User-Agent": "isep-stage/1 (+inkstone/ISEP#137)"}
|
||||||
|
if body is not None:
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(CF_API_BASE + path, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8") or "{}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(e.read().decode("utf-8") or "{}")
|
||||||
|
except Exception:
|
||||||
|
return e.code, {"success": False, "errors": [{"message": "HTTP %d(非 JSON 回應)" % e.code}]}
|
||||||
|
except Exception as e:
|
||||||
|
return None, "%s: %s" % (type(e).__name__, e)
|
||||||
|
|
||||||
|
|
||||||
|
def cf_ok(status, d):
|
||||||
|
# CF 對「建立」回 201(secret PUT 實測就是 201,inkstone/ISEP#137 第一次實跑抓到的)——2xx + success 才算好
|
||||||
|
return status is not None and 200 <= status < 300 and isinstance(d, dict) and bool(d.get("success"))
|
||||||
|
|
||||||
|
|
||||||
|
def cf_errors(d):
|
||||||
|
if isinstance(d, dict):
|
||||||
|
errs = d.get("errors") or []
|
||||||
|
return ";".join("%s(code %s)" % (e.get("message"), e.get("code")) for e in errs) or "(CF 沒給錯誤訊息)"
|
||||||
|
return str(d)
|
||||||
|
|
||||||
|
|
||||||
|
def refuse_account(given):
|
||||||
|
return die(EXIT_REFUSED, "\n".join([
|
||||||
|
"🛑 本工具只種 stage,不種 %s。" % given,
|
||||||
|
"",
|
||||||
|
" stage = %s,帳號 ID %s" % (STAGE_LABEL, STAGE_ACCOUNT_ID),
|
||||||
|
" 你給的 = %s" % given,
|
||||||
|
"",
|
||||||
|
"【為什麼】stage 隨便炸;出貨機(geek6688/uncle6)與 leo 的實例(leo21c)不是(inkstone/ISEP#137)。",
|
||||||
|
"【出路】要種進那兩類帳號 ⇒ 交回地端,由 leo 親手在 Mac 上 `wrangler secret put`;",
|
||||||
|
" 雲端這支不會、也不該會。要種 stage ⇒ 把 --account 拿掉(預設就是 stage)。",
|
||||||
|
]))
|
||||||
|
|
||||||
|
|
||||||
|
def check_token_reaches_stage(token, token_env):
|
||||||
|
status, d = cf("GET", "/accounts", token)
|
||||||
|
if status is None:
|
||||||
|
return die(EXIT_API, "🔴 連不到 CF API(%s):%s\n 雲端看到這個先查 `curl -sS \"$HTTPS_PROXY/__agentproxy/status\"`(policy 拒絕 vs DNS vs 對方沒回,三種修法不同)。" % (CF_API_BASE, d))
|
||||||
|
if not cf_ok(status, d):
|
||||||
|
return die(EXIT_API, "🔴 CF 不認這把 token(%s,HTTP %s):%s" % (token_env, status, cf_errors(d)))
|
||||||
|
accounts = [(a.get("id"), a.get("name")) for a in (d.get("result") or []) if isinstance(a, dict)]
|
||||||
|
if STAGE_ACCOUNT_ID not in [i for i, _ in accounts]:
|
||||||
|
reach = "\n".join(" · %s %s" % (i, n) for i, n in accounts) or " ·(一個都沒有)"
|
||||||
|
return die(EXIT_REFUSED, "\n".join([
|
||||||
|
"🛑 這把 token(%s)打不到 stage,拒絕。" % token_env,
|
||||||
|
"",
|
||||||
|
" stage = %s,帳號 ID %s" % (STAGE_LABEL, STAGE_ACCOUNT_ID),
|
||||||
|
" 這把 token 打得到的帳號:",
|
||||||
|
reach,
|
||||||
|
"",
|
||||||
|
"【為什麼】本工具只種 stage;token 是誰的就會種進誰的帳號,所以先問 CF 它打得到誰(inkstone/ISEP#137)。",
|
||||||
|
"【出路】用 stage 的那把:--token-env %s(雲端 Cloud environment 就有,本機在頂層 .env)。" % DEFAULT_TOKEN_ENV,
|
||||||
|
" 真要種出貨機/leo21c ⇒ 交回地端由 leo 親手做。",
|
||||||
|
]))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_names(token, account, worker):
|
||||||
|
status, d = cf("GET", "/accounts/%s/workers/scripts/%s/secrets" % (account, worker), token)
|
||||||
|
if status is None:
|
||||||
|
return None, "連不到 CF API(%s):%s" % (CF_API_BASE, d)
|
||||||
|
if not cf_ok(status, d):
|
||||||
|
return None, "列 secrets 失敗(HTTP %s):%s" % (status, cf_errors(d))
|
||||||
|
return sorted(s.get("name") for s in (d.get("result") or []) if isinstance(s, dict) and s.get("name")), None
|
||||||
|
|
||||||
|
|
||||||
|
def run_secret(argv):
|
||||||
|
try:
|
||||||
|
cmd, o = parse_secret(argv)
|
||||||
|
except Refuse as e:
|
||||||
|
return die(EXIT_REFUSED, "🛑 %s" % e)
|
||||||
|
|
||||||
|
if o["account"] != STAGE_ACCOUNT_ID:
|
||||||
|
return refuse_account(o["account"])
|
||||||
|
token = os.environ.get(o["token_env"], "")
|
||||||
|
if not token:
|
||||||
|
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(CF token)。\n【出路】雲端:Cloud environment 要有它(scripts/make-cloud-env.sh 的清單裡就有);本機:從頂層 .env 匯出。" % o["token_env"])
|
||||||
|
value = None
|
||||||
|
if cmd == "put":
|
||||||
|
value = os.environ.get(o["from_env"], "")
|
||||||
|
if not value:
|
||||||
|
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(要種進去的 secret 本身)。\n【出路】把值放進這個環境變數再跑;不要改成把值寫在指令列——那是紅線(值不落磁碟、不進指令歷史)。" % o["from_env"])
|
||||||
|
|
||||||
|
rc = check_token_reaches_stage(token, o["token_env"])
|
||||||
|
if rc is not None:
|
||||||
|
return rc
|
||||||
|
acct, worker = o["account"], o["worker"]
|
||||||
|
|
||||||
|
if cmd == "list":
|
||||||
|
names, err = list_names(token, acct, worker)
|
||||||
|
if err:
|
||||||
|
return die(EXIT_API, "🔴 %s" % err)
|
||||||
|
print("✅ %s@%s 現有 %d 把 secret:%s" % (worker, STAGE_LABEL, len(names), "、".join(names) or "(沒有)"))
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
if cmd == "delete":
|
||||||
|
status, d = cf("DELETE", "/accounts/%s/workers/scripts/%s/secrets/%s" % (acct, worker, o["secret"]), token)
|
||||||
|
if status is None:
|
||||||
|
return die(EXIT_API, "🔴 連不到 CF API:%s" % d)
|
||||||
|
if not cf_ok(status, d):
|
||||||
|
return die(EXIT_API, "🔴 刪 %s 失敗(HTTP %s):%s" % (o["secret"], status, cf_errors(d)))
|
||||||
|
names, err = list_names(token, acct, worker)
|
||||||
|
if err:
|
||||||
|
return die(EXIT_API, "🔴 刪了但列不出來確認:%s" % err)
|
||||||
|
if o["secret"] in names:
|
||||||
|
return die(EXIT_API, "🔴 CF 說刪了,列表卻還有 %s" % o["secret"])
|
||||||
|
print("✅ 已刪 %s ← %s@%s;剩 %d 把:%s" % (o["secret"], worker, STAGE_LABEL, len(names), "、".join(names) or "(沒有)"))
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
body = {"name": o["secret"], "text": value, "type": "secret_text"}
|
||||||
|
status, d = cf("PUT", "/accounts/%s/workers/scripts/%s/secrets" % (acct, worker), token, body)
|
||||||
|
del value, body
|
||||||
|
if status is None:
|
||||||
|
return die(EXIT_API, "🔴 連不到 CF API:%s" % d)
|
||||||
|
if not cf_ok(status, d):
|
||||||
|
return die(EXIT_API, "🔴 種 %s → %s 失敗(HTTP %s):%s" % (o["secret"], worker, status, cf_errors(d)))
|
||||||
|
names, err = list_names(token, acct, worker)
|
||||||
|
if err:
|
||||||
|
return die(EXIT_API, "🔴 CF 說種好了,但列不出來確認:%s" % err)
|
||||||
|
if o["secret"] not in names:
|
||||||
|
return die(EXIT_API, "🔴 CF 說種好了,列表卻沒有 %s(有的是:%s)" % (o["secret"], "、".join(names)))
|
||||||
|
print("✅ 已種 %s → %s@%s(值來自環境變數 %s,沒印、沒落地)" % (o["secret"], worker, STAGE_LABEL, o["from_env"]))
|
||||||
|
print(" 現有 %d 把:%s" % (len(names), "、".join(names)))
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── api(curl 打 stage 實例)─────────────────────────
|
||||||
|
|
||||||
|
def parse_api(argv):
|
||||||
|
if len(argv) < 2:
|
||||||
|
raise Refuse("api 後面要接 <METHOD> <worker>/<path>(--help 看用法)")
|
||||||
|
method, target = argv[0].upper(), argv[1]
|
||||||
|
if method not in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||||
|
raise Refuse("METHOD 要是 GET/POST/PUT/PATCH/DELETE,不是 %r" % argv[0])
|
||||||
|
o = {"json": None, "data_env": None, "data_file": None, "bearer_env": None, "header_envs": []}
|
||||||
|
i = 2
|
||||||
|
while i < len(argv):
|
||||||
|
a = argv[i]
|
||||||
|
if a in FORBIDDEN_VALUE_FLAGS:
|
||||||
|
raise Refuse("本工具不收 %s:標頭值只從環境變數名讀(--bearer-env/--header-env Name=VAR),不准出現在指令列" % a)
|
||||||
|
if i + 1 >= len(argv):
|
||||||
|
raise Refuse("%s 後面要接一個值" % a)
|
||||||
|
v = argv[i + 1]
|
||||||
|
if a == "--json":
|
||||||
|
o["json"] = v
|
||||||
|
elif a == "--data-env":
|
||||||
|
o["data_env"] = v
|
||||||
|
elif a == "--data-file":
|
||||||
|
o["data_file"] = v
|
||||||
|
elif a == "--bearer-env":
|
||||||
|
o["bearer_env"] = v
|
||||||
|
elif a == "--header-env":
|
||||||
|
if "=" not in v:
|
||||||
|
raise Refuse("--header-env 的形狀是 Name=VAR(例 X-Arcrun-API-Key=ARCRUN_NS_YOULIN),不是 %r" % v)
|
||||||
|
o["header_envs"].append(tuple(v.split("=", 1)))
|
||||||
|
else:
|
||||||
|
raise Refuse("不認得的參數:%s(--help 看用法)" % a)
|
||||||
|
i += 2
|
||||||
|
if sum(1 for k in ("json", "data_env", "data_file") if o[k]) > 1:
|
||||||
|
raise Refuse("--json/--data-env/--data-file 只能給一個")
|
||||||
|
return method, target, o
|
||||||
|
|
||||||
|
|
||||||
|
def stage_url(target):
|
||||||
|
"""把 <worker>/<path> 或完整網址變成 stage 的網址;主機不是 stage 就拒絕。"""
|
||||||
|
if "://" in target:
|
||||||
|
u = urllib.parse.urlsplit(target)
|
||||||
|
host = (u.hostname or "").lower()
|
||||||
|
if not host.endswith(STAGE_HOST_SUFFIX) or host == STAGE_HOST_SUFFIX.lstrip("."):
|
||||||
|
raise Refuse("\n".join([
|
||||||
|
"本工具只打 stage,不打 %s。" % host,
|
||||||
|
"",
|
||||||
|
" stage = %s,主機一律是 <worker>%s" % (STAGE_LABEL, STAGE_HOST_SUFFIX),
|
||||||
|
" 你給的 = %s" % target,
|
||||||
|
"",
|
||||||
|
"【為什麼】stage 隨便炸;leo21c 是 leo 本人在用的實例、geek6688/uncle6 是出貨機(inkstone/ISEP#137)。",
|
||||||
|
"【出路】打 stage ⇒ 寫成 <worker>/<path>(例 arcrun-kbdb/records),主機由本工具補。",
|
||||||
|
" 對 leo21c 唯讀查詢 ⇒ 直接 curl GET(leo21c-write-guard 放行讀);要寫 ⇒ 交回地端由 leo 親手做。",
|
||||||
|
]))
|
||||||
|
if API_BASE_OVERRIDE:
|
||||||
|
return API_BASE_OVERRIDE + "/" + host.split(".")[0] + (u.path or "/") + ("?" + u.query if u.query else "")
|
||||||
|
return urllib.parse.urlunsplit(("https", host, u.path or "/", u.query, ""))
|
||||||
|
if "/" not in target:
|
||||||
|
raise Refuse("目標要寫成 <worker>/<path>(例 arcrun-kbdb/records),不是 %r" % target)
|
||||||
|
worker, rest = target.split("/", 1)
|
||||||
|
if not worker or any(c in worker for c in ".:@ "):
|
||||||
|
raise Refuse("<worker> 只能是 worker 名(例 arcrun-kbdb),不是 %r" % worker)
|
||||||
|
if API_BASE_OVERRIDE:
|
||||||
|
return API_BASE_OVERRIDE + "/" + worker + "/" + rest
|
||||||
|
return "https://" + worker + STAGE_HOST_SUFFIX + "/" + rest
|
||||||
|
|
||||||
|
|
||||||
|
def curl_quote(s):
|
||||||
|
return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\t", "\\t") + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def run_api(argv):
|
||||||
|
try:
|
||||||
|
method, target, o = parse_api(argv)
|
||||||
|
url = stage_url(target)
|
||||||
|
except Refuse as e:
|
||||||
|
return die(EXIT_REFUSED, "🛑 %s" % e)
|
||||||
|
|
||||||
|
headers = []
|
||||||
|
if o["bearer_env"]:
|
||||||
|
v = os.environ.get(o["bearer_env"], "")
|
||||||
|
if not v:
|
||||||
|
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(Bearer)。\n【出路】把值放進這個環境變數再跑;不要改成寫在指令列。" % o["bearer_env"])
|
||||||
|
headers.append("Authorization: Bearer " + v)
|
||||||
|
for name, var in o["header_envs"]:
|
||||||
|
v = os.environ.get(var, "")
|
||||||
|
if not v:
|
||||||
|
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(標頭 %s)。" % (var, name))
|
||||||
|
headers.append(name + ": " + v)
|
||||||
|
|
||||||
|
body = None
|
||||||
|
if o["json"] is not None:
|
||||||
|
body = o["json"]
|
||||||
|
elif o["data_env"]:
|
||||||
|
body = os.environ.get(o["data_env"], "")
|
||||||
|
if not body:
|
||||||
|
return die(EXIT_REFUSED, "🛑 環境變數 %s 沒有值(body)。" % o["data_env"])
|
||||||
|
elif o["data_file"]:
|
||||||
|
try:
|
||||||
|
with open(o["data_file"], encoding="utf-8") as f:
|
||||||
|
body = f.read()
|
||||||
|
except OSError as e:
|
||||||
|
return die(EXIT_REFUSED, "🛑 讀不了 --data-file %s:%s" % (o["data_file"], e))
|
||||||
|
if body is not None:
|
||||||
|
headers.append("Content-Type: application/json")
|
||||||
|
|
||||||
|
# 標頭與 body 都不走 argv:設定檔從 stdin 進,body 從一條管線(/dev/fd/N)進
|
||||||
|
rfd, wfd = os.pipe()
|
||||||
|
cfg = ["url = " + curl_quote(url), "request = " + curl_quote(method), "silent", "show-error",
|
||||||
|
"write-out = " + curl_quote("\\n__STAGE_HTTP__%{http_code}"), "max-time = 120",
|
||||||
|
"user-agent = " + curl_quote("isep-stage/1 (+inkstone/ISEP#137)")]
|
||||||
|
for h in headers:
|
||||||
|
cfg.append("header = " + curl_quote(h))
|
||||||
|
if body is not None:
|
||||||
|
cfg.append("data-binary = " + curl_quote("@/dev/fd/%d" % rfd))
|
||||||
|
try:
|
||||||
|
p = subprocess.Popen(["curl", "--config", "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE, pass_fds=(rfd,) if body is not None else ())
|
||||||
|
except FileNotFoundError:
|
||||||
|
os.close(rfd); os.close(wfd)
|
||||||
|
return die(EXIT_API, "🔴 這台沒有 curl。")
|
||||||
|
os.close(rfd)
|
||||||
|
if body is not None:
|
||||||
|
with os.fdopen(wfd, "w", encoding="utf-8") as w:
|
||||||
|
w.write(body)
|
||||||
|
else:
|
||||||
|
os.close(wfd)
|
||||||
|
out, err = p.communicate(("\n".join(cfg) + "\n").encode("utf-8"))
|
||||||
|
del headers, cfg
|
||||||
|
text = out.decode("utf-8", "replace")
|
||||||
|
if p.returncode != 0:
|
||||||
|
return die(EXIT_API, "🔴 curl 失敗(離開碼 %d):%s\n 雲端 403 CONNECT ⇒ `curl -sS \"$HTTPS_PROXY/__agentproxy/status\"`(policy);`error code: 1010` ⇒ youlin 邊緣擋 UA。"
|
||||||
|
% (p.returncode, err.decode("utf-8", "replace").strip()))
|
||||||
|
resp, _, code = text.rpartition("\n__STAGE_HTTP__")
|
||||||
|
code = code.strip() or "000"
|
||||||
|
try:
|
||||||
|
pretty = json.dumps(json.loads(resp), ensure_ascii=False, indent=2) if resp.strip() else ""
|
||||||
|
except ValueError:
|
||||||
|
pretty = resp
|
||||||
|
print("HTTP %s %s %s" % (code, method, url))
|
||||||
|
if pretty:
|
||||||
|
print(pretty)
|
||||||
|
return EXIT_OK if code.isdigit() and 200 <= int(code) < 300 else EXIT_API
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────── 入口 ─────────────────────────
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if not argv or argv[0] in ("-h", "--help"):
|
||||||
|
print(__doc__)
|
||||||
|
return EXIT_OK
|
||||||
|
if argv[0] == "secret":
|
||||||
|
return run_secret(argv[1:])
|
||||||
|
if argv[0] == "api":
|
||||||
|
return run_api(argv[1:])
|
||||||
|
return die(EXIT_REFUSED, "🛑 第一個參數要是 secret/api,不是 %r(--help 看用法)" % argv[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -16,11 +16,11 @@ bad(){ FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
|
|||||||
N=$(python3 -c "import json;print(len(json.load(open('$LIST'))['allow']))")
|
N=$(python3 -c "import json;print(len(json.load(open('$LIST'))['allow']))")
|
||||||
|
|
||||||
echo "── A. 清單本身的形狀 ──"
|
echo "── A. 清單本身的形狀 ──"
|
||||||
python3 - "$LIST" <<'PY' && ok "A1 每一條都是 Bash(…) 形狀,而且四個正門工具都在場(ticket/mainline/gate-ok/gitea-pr-merge)" || bad "A1 清單形狀不對或少了正門工具"
|
python3 - "$LIST" <<'PY' && ok "A1 每一條都是 Bash(…) 形狀,而且六個正門工具都在場(ticket/mainline/gate-ok/gitea-pr-merge/stage/Arcrun 的 stage-deploy-artifacts.py)" || bad "A1 清單形狀不對或少了正門工具"
|
||||||
import json, sys
|
import json, sys
|
||||||
rules = json.load(open(sys.argv[1]))["allow"]
|
rules = json.load(open(sys.argv[1]))["allow"]
|
||||||
assert rules and all(r.startswith("Bash(") and r.endswith(")") for r in rules), rules
|
assert rules and all(r.startswith("Bash(") and r.endswith(")") for r in rules), rules
|
||||||
for tool in ("scripts/ticket", "scripts/mainline", "scripts/gate-ok", "scripts/gitea-pr-merge.sh"):
|
for tool in ("scripts/ticket", "scripts/mainline", "scripts/gate-ok", "scripts/gitea-pr-merge.sh", "scripts/stage ", "scripts/stage-deploy-artifacts.py"):
|
||||||
assert any(tool in r for r in rules), "缺 " + tool
|
assert any(tool in r for r in rules), "缺 " + tool
|
||||||
assert not any(r.strip() in ("Bash(python3 *)", "Bash(*)", "Bash(bash *)") for r in rules), "整類放行"
|
assert not any(r.strip() in ("Bash(python3 *)", "Bash(*)", "Bash(bash *)") for r in rules), "整類放行"
|
||||||
PY
|
PY
|
||||||
|
|||||||
Executable
+247
@@ -0,0 +1,247 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# scripts/stage 的測試(inkstone/ISEP#137)
|
||||||
|
#
|
||||||
|
# 它守什麼: 「雲端總管對 stage 的每一種寫入都有一支固定形狀的正門,而且**只**寫得進 stage」這件事的機器那一半。
|
||||||
|
# 09-07 三種寫入(`npx wrangler secret put`/`stage-deploy-artifacts.py`/curl POST 到 arcrun-yuga3bse)在雲端
|
||||||
|
# 全被 auto mode 分類器擋(複合指令與任意 curl 對不上白名單任何一條)。
|
||||||
|
# 收成 `stage secret …`(CF API)與 `stage api …`(curl 打實例);值只從環境變數名讀;
|
||||||
|
# 判準是 CF 帳號 ID/stage 主機往放行方向比對,不看名字。
|
||||||
|
#
|
||||||
|
# 🔴 全程離線:CF API 與 stage 實例都指到本機一個假伺服器,不打真 Cloudflare、不碰任何 worker。
|
||||||
|
# 真的種一把進 youlin、真的打 stage 實例的實跑在 docs/TESTING.md A36 另記。
|
||||||
|
# 假伺服器對 secret PUT 回 **201**——真 CF 就是回 201,第一次實跑把 201 講成失敗就是這樣抓到的。
|
||||||
|
#
|
||||||
|
# 四個方向都要驗,缺一個就是假綠:
|
||||||
|
# A 拒絕(非 stage/缺變數/值在指令列)⇒ 離開碼 2,而且**沒打網路**
|
||||||
|
# B token 打不到 stage ⇒ 拒絕、印出它打得到誰、沒有任何 PUT
|
||||||
|
# C 種進 stage ⇒ PUT 的路徑帶 stage ID、body 對、值不出現在任何輸出、種完用列表確認
|
||||||
|
# D api:主機只會是 stage;標頭值從環境變數來;body 三種來源;非 2xx 離開碼 1
|
||||||
|
# E 接線:白名單有它四種形狀+Arcrun 那支;ISEP 自己的 Bash 閘對這些呼叫形狀一支都不擋(誤攔比漏擋嚴重)
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
ROOT=$(pwd)
|
||||||
|
S=scripts/stage
|
||||||
|
STAGE=1129efd7df2e8899d537e9c8fbabb6cb
|
||||||
|
TMP=$(mktemp -d); trap 'kill $SRV 2>/dev/null; rm -rf "$TMP"' EXIT
|
||||||
|
PASS=0; FAIL=0
|
||||||
|
ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
|
||||||
|
bad(){ FAIL=$((FAIL+1)); printf ' ❌ %s\n' "$1"; }
|
||||||
|
LOG="$TMP/log.jsonl"; MODE="$TMP/mode"; STATE="$TMP/secrets.json"
|
||||||
|
export STAGE_TEST_VALUE="v-ISEP137-$(python3 -c 'import secrets;print(secrets.token_hex(12))')-must-never-print" # 每次隨機:C8 才驗得出「值落地」
|
||||||
|
export TOK_STAGE='tok-stage-abcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
export KBDB_TOK_TEST="kbdb-$(python3 -c 'import secrets;print(secrets.token_hex(8))')"
|
||||||
|
|
||||||
|
# ── 假伺服器:/accounts… 扮 CF API;/<worker>/… 扮 stage 實例。mode 檔決定 CF 扮誰 ──
|
||||||
|
cat > "$TMP/stub.py" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
TMP = sys.argv[1]; PORT = int(sys.argv[2])
|
||||||
|
STAGE = "1129efd7df2e8899d537e9c8fbabb6cb"
|
||||||
|
def mode(): return open(os.path.join(TMP, "mode")).read().strip()
|
||||||
|
def state():
|
||||||
|
p = os.path.join(TMP, "secrets.json")
|
||||||
|
return json.load(open(p)) if os.path.exists(p) else ["KBDB_INTERNAL_TOKEN"]
|
||||||
|
def save(s): json.dump(s, open(os.path.join(TMP, "secrets.json"), "w"))
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a): pass
|
||||||
|
def _send(self, code, obj):
|
||||||
|
b = json.dumps(obj).encode(); self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(b)))
|
||||||
|
self.end_headers(); self.wfile.write(b)
|
||||||
|
def _body(self):
|
||||||
|
n = int(self.headers.get("Content-Length", "0")); return self.rfile.read(n).decode() if n else ""
|
||||||
|
def _log(self, raw=None):
|
||||||
|
rec = {"method": self.command, "path": self.path, "auth": self.headers.get("Authorization", ""),
|
||||||
|
"ua": self.headers.get("User-Agent", ""), "ct": self.headers.get("Content-Type", ""),
|
||||||
|
"xkey": self.headers.get("X-Arcrun-API-Key", "")}
|
||||||
|
if raw is not None:
|
||||||
|
try: body = json.loads(raw)
|
||||||
|
except ValueError: body = {}
|
||||||
|
rec["body_name"] = body.get("name"); rec["body_type"] = body.get("type")
|
||||||
|
rec["text_ok"] = body.get("text") == os.environ.get("STAGE_TEST_VALUE")
|
||||||
|
rec["body_raw"] = raw if os.environ.get("STAGE_TEST_VALUE") not in raw else "<redacted>"
|
||||||
|
with open(os.path.join(TMP, "log.jsonl"), "a") as f: f.write(json.dumps(rec) + "\n")
|
||||||
|
def _instance(self):
|
||||||
|
# 扮 stage 實例:POST 沒帶 Bearer ⇒ 401(真 kbdb 就是這樣);其餘 200 回聲
|
||||||
|
raw = self._body(); self._log(raw)
|
||||||
|
if self.command != "GET" and not self.headers.get("Authorization"):
|
||||||
|
return self._send(401, {"error": "Unauthorized"})
|
||||||
|
self._send(200, {"ok": True, "echo_path": self.path, "echo_method": self.command})
|
||||||
|
def do_GET(self):
|
||||||
|
if not self.path.startswith("/accounts"): return self._instance()
|
||||||
|
self._log(); m = mode()
|
||||||
|
if self.path == "/accounts":
|
||||||
|
if m == "401": return self._send(401, {"success": False, "errors": [{"code": 10000, "message": "Authentication error"}]})
|
||||||
|
acct = {"stage": [{"id": STAGE, "name": "Youlin.hsieh.dev@gmail.com's Account"}],
|
||||||
|
"leo21c": [{"id": "9f3b2c1d000000000000000000000leo", "name": "Leo21c's Account"}],
|
||||||
|
"geek": [{"id": "77aa11bb000000000000000000000geek", "name": "geek6688's Account"}],
|
||||||
|
"both": [{"id": STAGE, "name": "Youlin"}, {"id": "9f3b2c1d000000000000000000000leo", "name": "Leo21c"}]}
|
||||||
|
return self._send(200, {"success": True, "errors": [], "result": acct.get(m, acct["stage"])})
|
||||||
|
if self.path.endswith("/secrets"):
|
||||||
|
names = state()
|
||||||
|
if m == "put-ok-list-missing": names = [n for n in names if n != "ISEP_137_PROBE"]
|
||||||
|
return self._send(200, {"success": True, "errors": [], "result": [{"name": n, "type": "secret_text"} for n in names]})
|
||||||
|
self._send(404, {"success": False, "errors": [{"code": 7003, "message": "not found"}]})
|
||||||
|
def do_PUT(self):
|
||||||
|
if not self.path.startswith("/accounts"): return self._instance()
|
||||||
|
raw = self._body(); self._log(raw); body = json.loads(raw or "{}")
|
||||||
|
s = state(); s = [x for x in s if x != body.get("name")] + [body.get("name")]; save(s)
|
||||||
|
self._send(201, {"success": True, "errors": [], "result": {"name": body.get("name"), "type": "secret_text"}}) # 真 CF 回 201
|
||||||
|
def do_DELETE(self):
|
||||||
|
if not self.path.startswith("/accounts"): return self._instance()
|
||||||
|
self._log(); name = self.path.rsplit("/", 1)[-1]
|
||||||
|
save([x for x in state() if x != name]); self._send(200, {"success": True, "errors": [], "result": None})
|
||||||
|
def do_POST(self): self._instance()
|
||||||
|
def do_PATCH(self): self._instance()
|
||||||
|
HTTPServer(("127.0.0.1", PORT), H).serve_forever()
|
||||||
|
PY
|
||||||
|
PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1])")
|
||||||
|
python3 "$TMP/stub.py" "$TMP" "$PORT" & SRV=$!
|
||||||
|
for _ in $(seq 1 50); do python3 -c "import socket;socket.create_connection(('127.0.0.1',$PORT),1)" 2>/dev/null && break; sleep 0.1; done
|
||||||
|
export STAGE_SECRET_PUT_API_BASE="http://127.0.0.1:$PORT"
|
||||||
|
export STAGE_API_BASE_OVERRIDE="http://127.0.0.1:$PORT"
|
||||||
|
echo stage > "$MODE"; : > "$LOG"
|
||||||
|
reset(){ : > "$LOG"; rm -f "$STATE"; echo "${1:-stage}" > "$MODE"; }
|
||||||
|
run(){ OUT=$(python3 "$S" "$@" 2>&1); RC=$?; }
|
||||||
|
hits(){ grep -c "\"method\": \"$1\"" "$LOG" 2>/dev/null || true; }
|
||||||
|
no_value(){ ! printf '%s' "$OUT" | grep -qF "$STAGE_TEST_VALUE"; }
|
||||||
|
|
||||||
|
echo "── A. 本工具自己拒絕的:離開碼 2,而且沒打網路 ──"
|
||||||
|
reset; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE --account 58309bb90fd93ad6d0fe0aae99170e9d
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q "$STAGE" && printf '%s' "$OUT" | grep -q '交回地端' && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||||
|
&& ok "A1 --account 給非 stage(uncle6 的 ID)⇒ 拒絕、講出 stage 是誰、出路是交回地端、一個請求都沒發" || bad "A1 rc=$RC hits=$(wc -l < "$LOG") $OUT"
|
||||||
|
reset; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env NOPE_TOKEN_VAR
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'NOPE_TOKEN_VAR' && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||||
|
&& ok "A2 token 的環境變數沒值 ⇒ 拒絕、點名那個變數、沒打網路" || bad "A2 rc=$RC $OUT"
|
||||||
|
reset; run secret put --worker w --secret X --from-env NOPE_VALUE_VAR --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'NOPE_VALUE_VAR' && printf '%s' "$OUT" | grep -q '紅線' && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||||
|
&& ok "A3 值的環境變數沒值 ⇒ 拒絕、點名變數、明講不准改寫進指令列、沒打網路" || bad "A3 rc=$RC $OUT"
|
||||||
|
reset; run secret put --worker w --secret X --value "$STAGE_TEST_VALUE" --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q -- '--value' && no_value && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||||
|
&& ok "A4 --value(值在指令列)⇒ 拒收,而且拒絕訊息裡不回顯那個值" || bad "A4 rc=$RC $OUT"
|
||||||
|
reset; run secret put --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q -- '--worker' && ok "A5 缺 --worker ⇒ 拒絕、講缺哪個" || bad "A5 rc=$RC $OUT"
|
||||||
|
reset; run frobnicate --worker w
|
||||||
|
[ "$RC" = 2 ] && ok "A6 不認得的子命令 ⇒ 拒絕" || bad "A6 rc=$RC $OUT"
|
||||||
|
run --help; [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q -- '--from-env' && printf '%s' "$OUT" | grep -q -- '--bearer-env' && ok "A7 --help 印兩個子命令的用法、離開碼 0" || bad "A7 rc=$RC"
|
||||||
|
|
||||||
|
echo "── B. token 打不到 stage ⇒ 拒絕、印出它打得到誰、沒有 PUT ──"
|
||||||
|
reset leo21c; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q "Leo21c's Account" && printf '%s' "$OUT" | grep -q "$STAGE" && [ "$(hits PUT)" = 0 ] && no_value \
|
||||||
|
&& ok "B1 token 只打得到 leo21c ⇒ 拒絕、列出那個帳號、講出 stage 是哪個 ID、零 PUT" || bad "B1 rc=$RC PUT=$(hits PUT) $OUT"
|
||||||
|
reset geek; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q "geek6688" && [ "$(hits PUT)" = 0 ] \
|
||||||
|
&& ok "B2 token 只打得到 geek6688(出貨機)⇒ 同樣拒絕、零 PUT" || bad "B2 rc=$RC $OUT"
|
||||||
|
reset 401; run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q 'Authentication error' && [ "$(hits PUT)" = 0 ] \
|
||||||
|
&& ok "B3 CF 回 401 ⇒ 離開碼 1(API 失敗,不是「拒絕」也不是 000)、帶 CF 的錯誤字串、零 PUT" || bad "B3 rc=$RC $OUT"
|
||||||
|
reset; STAGE_SECRET_PUT_API_BASE="http://127.0.0.1:1" run secret put --worker w --secret X --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q '__agentproxy/status' && no_value \
|
||||||
|
&& ok "B4 連不到 CF ⇒ 離開碼 1、印錯誤類型、指到 agentproxy status(不把三種病記成 000)" || bad "B4 rc=$RC $OUT"
|
||||||
|
reset both; run secret put --worker w --secret ISEP_137_PROBE --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 0 ] && grep -q "\"path\": \"/accounts/$STAGE/workers/scripts/w/secrets\"" "$LOG" && ! grep -q '0000leo' "$LOG" \
|
||||||
|
&& ok "B5 token 兩個帳號都打得到 ⇒ 放行,但 URL 只帶 stage 的 ID(寫入永遠只到 stage)" || bad "B5 rc=$RC $(cat "$LOG")"
|
||||||
|
|
||||||
|
echo "── C. 種進 stage(假 CF 對 PUT 回 201,跟真的一樣)──"
|
||||||
|
reset; run secret put --worker arcrun-cypher-executor --secret ISEP_137_PROBE --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 0 ] && ok "C1 離開碼 0(201 算成功——第一次實跑把 201 講成失敗,就是這格抓的)" || bad "C1 rc=$RC $OUT"
|
||||||
|
grep -q "\"method\": \"PUT\", \"path\": \"/accounts/$STAGE/workers/scripts/arcrun-cypher-executor/secrets\"" "$LOG" \
|
||||||
|
&& ok "C2 PUT 打的是 stage 帳號 ID 底下那顆 worker" || bad "C2 $(cat "$LOG")"
|
||||||
|
grep '"method": "PUT"' "$LOG" | grep -q '"body_name": "ISEP_137_PROBE", "body_type": "secret_text", "text_ok": true' \
|
||||||
|
&& ok "C3 body 的 name/type 對,而且 text 就是環境變數裡那個值(不是變數名)" || bad "C3 $(grep PUT "$LOG")"
|
||||||
|
no_value && ok "C4 stdout/stderr 不含那個值" || bad "C4 值印出來了"
|
||||||
|
grep -q '"auth": "Bearer tok-stage-' "$LOG" && ok "C5 Authorization 帶的是 --token-env 指的那把" || bad "C5 $(head -1 "$LOG")"
|
||||||
|
printf '%s' "$OUT" | grep -q 'ISEP_137_PROBE' && printf '%s' "$OUT" | grep -q 'KBDB_INTERNAL_TOKEN' \
|
||||||
|
&& ok "C6 種完把列表印出來(新的那把+原本就有的)" || bad "C6 $OUT"
|
||||||
|
[ "$(grep -c '"method": "GET", "path": "/accounts/'"$STAGE"'/workers/scripts/arcrun-cypher-executor/secrets"' "$LOG")" = 1 ] \
|
||||||
|
&& ok "C7 種完真的去列了一次(不是只信 PUT 的回應)" || bad "C7 $(cat "$LOG")"
|
||||||
|
! grep -rqF "$STAGE_TEST_VALUE" "$ROOT" 2>/dev/null \
|
||||||
|
&& ok "C8 值沒落在 repo 樹裡任何檔案" || bad "C8 值落地了:$(grep -rlF "$STAGE_TEST_VALUE" "$ROOT")"
|
||||||
|
reset put-ok-list-missing; run secret put --worker w --secret ISEP_137_PROBE --from-env STAGE_TEST_VALUE --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q '列表卻沒有' \
|
||||||
|
&& ok "C9 CF 說成功但列表沒有 ⇒ 離開碼 1(不把 2xx 講成種好了)" || bad "C9 rc=$RC $OUT"
|
||||||
|
reset; run secret list --worker w --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q 'KBDB_INTERNAL_TOKEN' && [ "$(hits PUT)" = 0 ] && ok "C10 secret list 印名字、不寫" || bad "C10 rc=$RC $OUT"
|
||||||
|
reset; run secret delete --worker w --secret KBDB_INTERNAL_TOKEN --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 0 ] && grep -q "\"method\": \"DELETE\", \"path\": \"/accounts/$STAGE/workers/scripts/w/secrets/KBDB_INTERNAL_TOKEN\"" "$LOG" \
|
||||||
|
&& ok "C11 secret delete 打對路徑、刪完列表確認" || bad "C11 rc=$RC $OUT $(cat "$LOG")"
|
||||||
|
reset leo21c; run secret delete --worker w --secret X --token-env TOK_STAGE
|
||||||
|
[ "$RC" = 2 ] && [ "$(hits DELETE)" = 0 ] && ok "C12 delete 對非 stage 的 token 一樣拒絕" || bad "C12 rc=$RC"
|
||||||
|
|
||||||
|
echo "── D. api:主機只會是 stage、標頭值從環境變數來、body 三種來源 ──"
|
||||||
|
reset; run api POST https://arcrun-cypher-executor.leo21c.workers.dev/kbdb/entries --json '{}'
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'leo21c.workers.dev' && printf '%s' "$OUT" | grep -q 'arcrun-yuga3bse' && [ "$(wc -l < "$LOG")" = 0 ] \
|
||||||
|
&& ok "D1 完整網址指到 leo21c ⇒ 拒絕、講出 stage 主機是什麼、沒打網路" || bad "D1 rc=$RC $OUT"
|
||||||
|
reset; run api POST https://arcrun-kbdb.youlin-hsieh-dev.workers.dev/records --json '{}'
|
||||||
|
[ "$RC" = 2 ] && [ "$(wc -l < "$LOG")" = 0 ] && ok "D2 09-02 已死的舊子網域 youlin-hsieh-dev 也拒絕(不是 stage 的識別碼)" || bad "D2 rc=$RC $OUT"
|
||||||
|
reset; run api POST arcrun-kbdb/records --bearer 'Bearer xyz' --json '{}'
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q -- '--bearer' && [ "$(wc -l < "$LOG")" = 0 ] && ok "D3 --bearer/--header(值在指令列)⇒ 拒收" || bad "D3 rc=$RC $OUT"
|
||||||
|
reset; run api POST arcrun-kbdb/records --bearer-env NOPE_KBDB --json '{}'
|
||||||
|
[ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'NOPE_KBDB' && [ "$(wc -l < "$LOG")" = 0 ] && ok "D4 --bearer-env 指的變數沒值 ⇒ 拒絕、點名" || bad "D4 rc=$RC $OUT"
|
||||||
|
reset; run api POST 'arcrun-kbdb/map/recompute?library=rt-lib&owner_id=yuga3bse' --bearer-env KBDB_TOK_TEST
|
||||||
|
[ "$RC" = 0 ] && grep -q '"method": "POST", "path": "/arcrun-kbdb/map/recompute?library=rt-lib&owner_id=yuga3bse", "auth": "Bearer kbdb-' "$LOG" \
|
||||||
|
&& printf '%s' "$OUT" | grep -q '^HTTP 200' && ok "D5 POST <worker>/<path>?query:主機由工具補、query 原樣、Bearer 從環境變數來、印 HTTP 200、離開碼 0" || bad "D5 rc=$RC $OUT $(cat "$LOG")"
|
||||||
|
reset; run api POST arcrun-kbdb/records --bearer-env KBDB_TOK_TEST --json '{"template":"triplet","values":{"s":"a"}}'
|
||||||
|
grep -q '"ct": "application/json"' "$LOG" && grep -q '"body_raw": "{\\"template\\":\\"triplet\\",\\"values\\":{\\"s\\":\\"a\\"}}"' "$LOG" \
|
||||||
|
&& ok "D6 --json:body 原樣送到、自動帶 Content-Type: application/json" || bad "D6 $(cat "$LOG")"
|
||||||
|
reset; STAGE_BODY_TEST='{"secret_ref":"'"$STAGE_TEST_VALUE"'"}' run api PUT arcrun-cypher-executor/credentials/gitea_token --bearer-env KBDB_TOK_TEST --data-env STAGE_BODY_TEST
|
||||||
|
[ "$RC" = 0 ] && grep -q '"method": "PUT", "path": "/arcrun-cypher-executor/credentials/gitea_token"' "$LOG" && grep -q '"body_raw": "<redacted>"' "$LOG" && no_value \
|
||||||
|
&& ok "D7 --data-env:body 從環境變數來(機敏 body 不進指令列),值沒印出來" || bad "D7 rc=$RC $OUT $(cat "$LOG")"
|
||||||
|
reset; printf '{"x":1}' > "$TMP/body.json"; run api POST arcrun-kbdb/records --bearer-env KBDB_TOK_TEST --data-file "$TMP/body.json"
|
||||||
|
[ "$RC" = 0 ] && grep -q '"body_raw": "{\\"x\\":1}"' "$LOG" && ok "D8 --data-file:body 從檔案來" || bad "D8 rc=$RC $(cat "$LOG")"
|
||||||
|
reset; run api POST arcrun-kbdb/records --json '{}'
|
||||||
|
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q '^HTTP 401' && ok "D9 沒帶 Bearer 的 POST ⇒ 實例回 401 ⇒ 離開碼 1(不把非 2xx 講成成功)" || bad "D9 rc=$RC $OUT"
|
||||||
|
reset; run api GET arcrun-cypher-executor/health
|
||||||
|
[ "$RC" = 0 ] && grep -q '"method": "GET", "path": "/arcrun-cypher-executor/health"' "$LOG" && ok "D10 GET 不帶 body、不帶 Content-Type" || bad "D10 rc=$RC $OUT"
|
||||||
|
grep -q '"ct": ""' "$LOG" && ok "D11 GET 的請求真的沒有 Content-Type" || bad "D11 $(cat "$LOG")"
|
||||||
|
reset; run api POST arcrun-cypher-executor/webhooks/named/yuga3bse/x/trigger --header-env X-Arcrun-API-Key=KBDB_TOK_TEST --bearer-env KBDB_TOK_TEST --json '{}'
|
||||||
|
grep -q '"xkey": "kbdb-' "$LOG" && ok "D12 --header-env Name=VAR:任意標頭值從環境變數來" || bad "D12 $(cat "$LOG")"
|
||||||
|
reset; run api POST https://arcrun-kbdb.arcrun-yuga3bse.workers.dev/records --bearer-env KBDB_TOK_TEST --json '{}'
|
||||||
|
[ "$RC" = 0 ] && grep -q '"path": "/arcrun-kbdb/records"' "$LOG" && ok "D13 完整網址但主機就是 stage ⇒ 收" || bad "D13 rc=$RC $OUT"
|
||||||
|
reset; run api POST 'arcrun-kbdb/records' --bearer-env KBDB_TOK_TEST --json '{}' --data-file x
|
||||||
|
[ "$RC" = 2 ] && [ "$(wc -l < "$LOG")" = 0 ] && ok "D14 --json 與 --data-file 同時給 ⇒ 拒絕" || bad "D14 rc=$RC"
|
||||||
|
reset; run api FROB arcrun-kbdb/records
|
||||||
|
[ "$RC" = 2 ] && ok "D15 不認得的 METHOD ⇒ 拒絕" || bad "D15 rc=$RC"
|
||||||
|
reset; STAGE_API_BASE_OVERRIDE="http://127.0.0.1:1" run api GET arcrun-cypher-executor/health
|
||||||
|
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q 'curl 失敗' && printf '%s' "$OUT" | grep -q '__agentproxy/status' && ok "D16 curl 連不上 ⇒ 離開碼 1、印 curl 的錯誤字串與 agentproxy 出路(不記成 000)" || bad "D16 rc=$RC $OUT"
|
||||||
|
grep -q '"ua": "isep-stage/1' "$LOG" 2>/dev/null || { reset; run api GET arcrun-cypher-executor/health; }
|
||||||
|
grep -q '"ua": "isep-stage/1' "$LOG" && ok "D17 UA 是自己的名字(youlin 邊緣擋 python-urllib UA,Arcrun#176 comment 6487)" || bad "D17 $(cat "$LOG")"
|
||||||
|
|
||||||
|
echo "── E. 接線 ──"
|
||||||
|
python3 - <<'PY' && ok "E1 docs/permissions-allow.json 有 stage 四種呼叫形狀+Arcrun 的 stage-deploy-artifacts.py,沒放整類" || bad "E1 白名單形狀不齊或放了整類"
|
||||||
|
import json
|
||||||
|
rules = json.load(open("docs/permissions-allow.json"))["allow"]
|
||||||
|
need = ['Bash(python3 ~/.claude/plugins/cache/inkstone/isep/*/scripts/stage *)',
|
||||||
|
'Bash(python3 "$CLAUDE_PLUGIN_ROOT/scripts/stage" *)',
|
||||||
|
'Bash(python3 "$CLAUDE_PLUGIN_ROOT"/scripts/stage *)',
|
||||||
|
'Bash(python3 scripts/stage *)',
|
||||||
|
'Bash(python3 scripts/stage-deploy-artifacts.py *)']
|
||||||
|
for n in need: assert n in rules, "缺 " + n
|
||||||
|
assert not any(r in ("Bash(npx *)", "Bash(wrangler *)", "Bash(npx wrangler *)", "Bash(python3 *)", "Bash(curl *)") for r in rules)
|
||||||
|
PY
|
||||||
|
# ISEP 自己掛在 PreToolUse Bash 上的每一支閘,對三種真實呼叫形狀都不准擋(清單當場從 hooks.json 讀,不寫死)
|
||||||
|
BLOCKED=""
|
||||||
|
while IFS= read -r CMD; do
|
||||||
|
for h in $(python3 -c "
|
||||||
|
import json
|
||||||
|
d=json.load(open('hooks/hooks.json'))
|
||||||
|
for g in d['hooks']['PreToolUse']:
|
||||||
|
if 'Bash' in (g.get('matcher') or ''):
|
||||||
|
for x in g['hooks']: print(x['command'].split('/')[-1])
|
||||||
|
"); do
|
||||||
|
[ -f "hooks/$h" ] || continue
|
||||||
|
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$CMD")},\"session_id\":\"t137\",\"cwd\":\"$ROOT\"}" \
|
||||||
|
| CLAUDE_PLUGIN_ROOT="$ROOT" CLAUDE_PROJECT_DIR="$ROOT" bash "hooks/$h" >/dev/null 2>"$TMP/hook.err"; rc=$?
|
||||||
|
[ "$rc" = 2 ] && BLOCKED="$BLOCKED $h←$(printf '%s' "$CMD" | cut -c1-40)"
|
||||||
|
done
|
||||||
|
done <<'CMDS'
|
||||||
|
python3 ~/.claude/plugins/cache/inkstone/isep/0.24.0/scripts/stage secret put --worker arcrun-cypher-executor --secret CF_SECRETS_API_TOKEN --from-env CF_SECRETS_API_TOKEN_VALUE
|
||||||
|
python3 "$CLAUDE_PLUGIN_ROOT/scripts/stage" api POST 'arcrun-kbdb/map/recompute?library=rt-lib&owner_id=yuga3bse' --bearer-env KBDB_INTERNAL_TOKEN
|
||||||
|
python3 scripts/stage-deploy-artifacts.py all --confirm
|
||||||
|
CMDS
|
||||||
|
[ -z "$BLOCKED" ] && ok "E2 ISEP 掛在 Bash 上的閘,對三種真實呼叫形狀(含 secret 字樣、POST、--confirm)一支都不擋——誤攔比漏擋嚴重" || bad "E2 這幾支誤攔:$BLOCKED"
|
||||||
|
grep -q 'scripts/stage ' scripts/test-settings-allow-sync.sh && grep -q 'stage-deploy-artifacts.py' scripts/test-settings-allow-sync.sh \
|
||||||
|
&& ok "E3 settings-allow-sync 的測試把兩支都算進正門工具(少了會紅)" || bad "E3 settings-allow-sync 測試不認得它們"
|
||||||
|
[ ! -e scripts/stage-secret-put ] && ok "E4 沒有第二支平行的工具(stage-secret-put 已收進 stage secret)" || bad "E4 還留著 stage-secret-put"
|
||||||
|
|
||||||
|
echo; echo "$PASS/$((PASS+FAIL)) 通過"
|
||||||
|
[ "$FAIL" -eq 0 ]
|
||||||
@@ -295,3 +295,31 @@ leo 當場:「**這些為什麼不寫到票裡?**」
|
|||||||
正確做法: `repo_arg()` 兩種寫法都收;org 寫錯(`Leo/`)當場講「org 是 inkstone」,不讓它變成一個要人猜的 404。
|
正確做法: `repo_arg()` 兩種寫法都收;org 寫錯(`Leo/`)當場講「org 是 inkstone」,不讓它變成一個要人猜的 404。
|
||||||
測試 `scripts/test-ticket-repo-arg.sh` 把 `api()` 換成假貨記路徑,離線驗路徑裡不准出現 `inkstone/inkstone`。
|
測試 `scripts/test-ticket-repo-arg.sh` 把 `api()` 換成假貨記路徑,離線驗路徑裡不准出現 `inkstone/inkstone`。
|
||||||
|
|
||||||
|
|
||||||
|
## ⚠️ MISTAKE: 權限白名單是「逐條比前綴」,複合指令永遠對不上——而假伺服器照自己的想像回 200
|
||||||
|
|
||||||
|
票: `inkstone/ISEP#137`(母票 `inkstone/ISEP#130`)
|
||||||
|
日期: 2026-09-07
|
||||||
|
|
||||||
|
症狀: 三條主線工人在雲端對 stage 的寫入全被 Claude Code auto mode 分類器擋:
|
||||||
|
`printf … | npx wrangler secret put …`、`python3 scripts/stage-deploy-artifacts.py all --confirm`、
|
||||||
|
`curl -X POST …arcrun-yuga3bse.workers.dev/records`。同一回合連 `ticket where <含 token 字樣>` 都被擋。
|
||||||
|
leo:「為什麼會擋你,這就是要修復的問題」「本機環境跟你不同應該不行」。
|
||||||
|
|
||||||
|
實查: `docs/permissions-allow.json` 的每一條是**完全比對前綴**。`cd … && printf … | VAR=… npx …` 這種複合指令
|
||||||
|
沒有任何一條對得上,任意寫法的 curl 更不可能;放整類(`Bash(npx *)`/`Bash(curl *)`)又是紅線。
|
||||||
|
⇒ 分類器擋的不是「這件事」,是「這個形狀沒被事先放行」。
|
||||||
|
|
||||||
|
原因: 正門工具的價值在**形狀固定**——一條規則就能事先放行。之前四個正門(ticket/mainline/gate-ok/gitea-pr-merge)
|
||||||
|
都是這樣活下來的;對 stage 的寫入沒有正門,人就只能拼複合指令,而複合指令天生對不上任何前綴。
|
||||||
|
|
||||||
|
正確做法:
|
||||||
|
- 每一種要放行的動作收成一支固定形狀(`scripts/stage secret …`/`stage api …`),白名單放它四種呼叫形狀。
|
||||||
|
「只打 stage」寫死在程式裡(帳號 ID 現查、主機後綴固定),不靠變數名字裡有沒有 `leo21c`。
|
||||||
|
- 別的 repo 已經有的正門(Arcrun `stage-deploy-artifacts.py` 自己寫死 youlin 帳號 ID)**直接放它的形狀**,不重造一支。
|
||||||
|
- 值只從環境變數名讀;`--value`/`--bearer`/`--header` 一律拒收——指令列會進 shell 歷史與 session log。
|
||||||
|
|
||||||
|
📌 同票第二個坑:**假伺服器要照真 API 回,不是照自己的想像回。**
|
||||||
|
離線測試的假 CF 對 secret PUT 回 200,27/27 全綠;第一次真跑 CF 回 **201**,工具把它講成「失敗」——
|
||||||
|
而獨立用 CF API 一列,那把 secret 明明種進去了。全綠的測試證明的是「工具跟假伺服器合得來」,
|
||||||
|
不是「工具跟 CF 合得來」。⇒ 假伺服器的回應碼要抄真的(現在回 201),而且**離線全綠之後一定要真跑一次**。
|
||||||
|
|||||||
Reference in New Issue
Block a user