@desplega.ai/agent-swarm 1.161.0 → 1.162.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/{acp-adapter-xx489vvb.js → acp-adapter-74m73ae9.js} +1 -1
- package/dist/{actions-ykjkn0m9.js → actions-4sr14gnt.js} +34 -31
- package/dist/{anthropic-messages-5qx80j9k.js → anthropic-messages-8xxrzky9.js} +22 -26
- package/dist/{app-0hdqa4k1.js → app-pt68fbwp.js} +13 -11
- package/dist/{artifact-3v5t9jtg.js → artifact-fey7dvj9.js} +3 -2
- package/dist/{assistant-am2g5g8a.js → assistant-cv3jytks.js} +37 -34
- package/dist/{azure-openai-responses-fszbkp0e.js → azure-openai-responses-f5cqsyj8.js} +15 -10
- package/dist/{boot-reembed-xy9t7avg.js → boot-reembed-4ht4gqth.js} +13 -11
- package/dist/{boot-reembed-gt5gjkt4.js → boot-reembed-nxpcvpj9.js} +12 -10
- package/dist/{boot-scrub-logs-9x0985y5.js → boot-scrub-logs-k93kyw8k.js} +9 -7
- package/dist/{boot-seeds-trv0d4vg.js → boot-seeds-1skcrs0e.js} +13 -10
- package/dist/{claude-adapter-jb6e1e54.js → claude-adapter-r5g0tzge.js} +23 -20
- package/dist/{claude-managed-setup-d1q9hv2b.js → claude-managed-setup-pkyrgshb.js} +3 -2
- package/dist/{claude-sdk-session-zga700s2.js → claude-sdk-session-jxyxnrwd.js} +5565 -5512
- package/dist/{cli-we0q7t0e.js → cli-2zy95vhm.js} +137 -64
- package/dist/{cli-35pj2yva.js → cli-4cwxn7gp.js} +38 -11
- package/dist/{cli-ad4qxm6j.js → cli-4dcpaefp.js} +2 -2
- package/dist/{cli-82c1pacn.js → cli-4x0csdjq.js} +71 -37
- package/dist/{cli-sqm3r5ps.js → cli-5bcd7sfy.js} +14 -18
- package/dist/{cli-d56tgd45.js → cli-5k6k8ta3.js} +2 -2
- package/dist/{cli-1er0rhz8.js → cli-5xhpb3kv.js} +15 -15
- package/dist/{cli-yrg7c73k.js → cli-7af8yhav.js} +10 -183
- package/dist/{cli-r6fsxc76.js → cli-7tre70zp.js} +513 -961
- package/dist/{cli-bh7jed3k.js → cli-8ev9xqnx.js} +34 -18
- package/dist/cli-bsky8g7w.js +449 -0
- package/dist/{cli-hn04t9db.js → cli-cebmgwsc.js} +14 -10
- package/dist/{cli-cfkrztza.js → cli-cvatsam7.js} +2 -2
- package/dist/{cli-2658f6at.js → cli-cvk6yzw5.js} +6 -8
- package/dist/{cli-ptr8h7kp.js → cli-eg0b3p0k.js} +5 -218
- package/dist/{cli-zb6xbk61.js → cli-g739zqzt.js} +4 -4
- package/dist/{cli-dv6cdjs2.js → cli-gd4favx9.js} +28 -7
- package/dist/{cli-5p3x4zna.js → cli-gpqpmns7.js} +2 -2
- package/dist/{cli-5nbmpy8w.js → cli-hb5c2x5y.js} +1 -1
- package/dist/{cli-jpf7e1zq.js → cli-hmsw1gpy.js} +3 -3
- package/dist/{cli-1cz0c18q.js → cli-jf7wmv1r.js} +2521 -733
- package/dist/{cli-qwsnmjdd.js → cli-jxs2ygaw.js} +168 -61
- package/dist/cli-kdkwd3yr.js +615 -0
- package/dist/{cli-hgmnf38m.js → cli-kh04eqwe.js} +1 -1
- package/dist/{cli-0383bp7f.js → cli-kp42yek0.js} +425 -90
- package/dist/{cli-ybgv4kw2.js → cli-mbssg1xv.js} +9 -2
- package/dist/{cli-jsp14b6c.js → cli-mzfth0bp.js} +38 -1
- package/dist/{cli-8g6mc14k.js → cli-nq7yqw7b.js} +16 -5
- package/dist/{cli-ee8bc46j.js → cli-nt8hecg9.js} +33 -32
- package/dist/{cli-9s68w752.js → cli-p0r8sxj5.js} +1 -1
- package/dist/{cli-58e8yr0d.js → cli-pkd6s2wy.js} +4 -2
- package/dist/{cli-17xpf7cm.js → cli-q24re23n.js} +6 -4
- package/dist/cli-qjjvzwzd.js +222 -0
- package/dist/cli-qrgt5bqf.js +189 -0
- package/dist/{cli-et8m9xsv.js → cli-rfjw9jw2.js} +22 -10
- package/dist/{cli-798vkbks.js → cli-rj0gqwwp.js} +2 -2
- package/dist/{cli-121ph74b.js → cli-rnyzn44p.js} +1 -1
- package/dist/{cli-vnnz5ncg.js → cli-s3hwpt9v.js} +95 -16
- package/dist/{cli-7119ja3w.js → cli-sptrj5f5.js} +2 -2
- package/dist/{cli-a8c82p2t.js → cli-sqvfa090.js} +13 -6
- package/dist/{cli-phfg8vy8.js → cli-swfmp1fx.js} +26 -26
- package/dist/{cli-nsdxtcbg.js → cli-tcen9p5w.js} +8 -5
- package/dist/{cli-9m03pzga.js → cli-vc9bp061.js} +5 -5
- package/dist/{cli-rtx9dygp.js → cli-vxe6qa4h.js} +1 -1
- package/dist/{cli-ecfxwnyb.js → cli-vy3ed88x.js} +1 -1
- package/dist/{cli-0c7ata9h.js → cli-vyqs9ya8.js} +6 -6
- package/dist/cli-wbvnn6tk.js +861 -0
- package/dist/{cli-kgd3p0bm.js → cli-wjgs5wxe.js} +6 -6
- package/dist/cli-wxkkd0e0.js +33 -0
- package/dist/{cli-akfj32vj.js → cli-xqpk3rp7.js} +5 -4
- package/dist/cli-zp38xvtm.js +9487 -0
- package/dist/{cli-3zc55ctx.js → cli-zvhd3f64.js} +2 -2
- package/dist/{cli-ze1d3wzv.js → cli-zxqx25nz.js} +5 -5
- package/dist/cli.js +32 -25
- package/dist/{cloudflare-workers-ai-system-one-k6fbxw9d.js → cloudflare-workers-ai-system-one-k8ddqjkq.js} +8 -6
- package/dist/{codex-adapter-04v97vep.js → codex-adapter-esvmnhn7.js} +13 -13
- package/dist/{codex-hook-20me6byw.js → codex-hook-m73jmqgx.js} +19 -3
- package/dist/{codex-session-runner-ax69mqq3.js → codex-session-runner-ban9excr.js} +13 -13
- package/dist/{commands-18gy08n1.js → commands-75413p2n.js} +9 -7
- package/dist/{db-d2pvjpyt.js → db-7f5pecwg.js} +11 -7
- package/dist/{dispatcher-8wbwyzjg.js → dispatcher-fh9at8nz.js} +33 -30
- package/dist/{dsh-adapter-ysqk9snv.js → dsh-adapter-wpze65d4.js} +3 -3
- package/dist/{e2b-esmt1r16.js → e2b-gjgdjp91.js} +10 -9
- package/dist/{pi-logo-animation-e9a0t68z.js → easter-egg-3d-3w5c2n4c.js} +122 -68
- package/dist/{execute-cmagrbrz.js → execute-f50wg9nh.js} +29 -6
- package/dist/{google-generative-ai-k6n5nbpp.js → google-generative-ai-jxxpnegy.js} +2 -2
- package/dist/{google-vertex-44xxtcvp.js → google-vertex-x7q1angm.js} +5 -5
- package/dist/{handlers-6b69m5kp.js → handlers-w13787bb.js} +36 -33
- package/dist/{hook-srnkt0hb.js → hook-46g1jrvx.js} +28 -15
- package/dist/{hook-k3mydf2n.js → hook-4tpts2ry.js} +20 -17
- package/dist/{http-hrf80ca1.js → http-19pzbfrt.js} +964 -176
- package/dist/{index-9rxdvj6w.js → index-8fd4dm4h.js} +34 -31
- package/dist/{index-1asbaj62.js → index-dqmp15bq.js} +35 -32
- package/dist/{index-45evq4ee.js → index-qb4q1y3e.js} +33 -30
- package/dist/{index-k4nj18qs.js → index-s75wc4wd.js} +12 -10
- package/dist/{index-xmevk2b9.js → index-scptvdxg.js} +27 -25
- package/dist/{keepalive-sfdz6ksp.js → keepalive-xa5ts9cd.js} +11 -9
- package/dist/{lead-ej91e9hw.js → lead-qp4xkvjq.js} +45 -41
- package/dist/{maintenance-80dnd7ke.js → maintenance-rm5885hm.js} +13 -11
- package/dist/{mistral-conversations-pc3ntnxt.js → mistral-conversations-rw7fbt22.js} +4 -4
- package/dist/{node-a5e00qhp.js → node-8zkdq42h.js} +834 -833
- package/dist/{oauth-refresh-sweep-m1etk2mx.js → oauth-refresh-sweep-1csdfebf.js} +10 -8
- package/dist/{onboard-0q8sdfqc.js → onboard-r6b78cs4.js} +10 -10
- package/dist/{openai-codex-responses-n7n683p2.js → openai-codex-responses-efvrgrq3.js} +4 -4
- package/dist/{openai-completions-jpaj5g2b.js → openai-completions-bbbrrz4z.js} +12 -8
- package/dist/{openai-responses-9a3nme9j.js → openai-responses-9jjyrcm1.js} +15 -10
- package/dist/{opencode-adapter-4wcgmvgf.js → opencode-adapter-kb3g6pkf.js} +2 -2
- package/dist/{otel-impl-hnqm9mxq.js → otel-impl-0y9knpky.js} +5 -5
- package/dist/{pi-mono-adapter-d6sqm9wr.js → pi-mono-adapter-z6s1bme2.js} +100 -19
- package/dist/{pricing-refresh-y60z64pm.js → pricing-refresh-mth26tgp.js} +11 -9
- package/dist/{rbac-roles-8pa0754b.js → rbac-roles-kwntb23q.js} +13 -9
- package/dist/rbac-roles-vpk4ebm8.js +45 -0
- package/dist/{registry-v5wytg0s.js → registry-km6t1jd2.js} +1 -1
- package/dist/{render-v2-418s1vj7.js → render-v2-hcm7h00y.js} +13 -11
- package/dist/{runtime-3pknc168.js → runtime-vfjsfsc5.js} +4 -4
- package/dist/{setup-yghdkj4x.js → setup-hbyzd65h.js} +2 -2
- package/dist/{virtual-modules-jcceevt5.js → virtual-modules-9bv7ry91.js} +9 -8
- package/dist/{work-objects-j1sz20v9.js → work-objects-m09sxnhe.js} +36 -33
- package/dist/{worker-d4gcvkjt.js → worker-0h35rvk4.js} +45 -41
- package/openapi.json +491 -12
- package/package.json +13 -6
- package/src/be/api-drain.ts +184 -0
- package/src/be/db/tasks/read.ts +31 -6
- package/src/be/db/tasks/write.ts +5 -4
- package/src/be/db.ts +42 -18
- package/src/be/memory/link-resolver.ts +29 -3
- package/src/be/migrations/192_favorites_agent_fs_path.sql +36 -0
- package/src/be/onboarding.ts +7 -1
- package/src/be/seed-prompt-templates.ts +1 -0
- package/src/be/swarm-config-guard.ts +35 -16
- package/src/be/task-citations.ts +11 -5
- package/src/be/users.ts +3 -0
- package/src/cli.tsx +3 -2
- package/src/comb/agent-fs.ts +39 -0
- package/src/comb/config.ts +37 -0
- package/src/comb/markers.ts +37 -0
- package/src/comb/review-batch.ts +486 -0
- package/src/comb/templates.ts +88 -0
- package/src/commands/context-preamble.ts +4 -1
- package/src/commands/hook.ts +2 -1
- package/src/commands/runner.ts +262 -68
- package/src/commands/templates.ts +10 -0
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/fs/agent-fs-provider.ts +106 -2
- package/src/heartbeat/heartbeat.ts +4 -0
- package/src/hooks/codex-hook.ts +25 -1
- package/src/hooks/hook.ts +13 -0
- package/src/hooks/pr-body-guard.ts +983 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/assets.ts +5 -17
- package/src/http/comb.ts +131 -0
- package/src/http/core.ts +2 -1
- package/src/http/fs.ts +9 -17
- package/src/http/index.ts +23 -9
- package/src/http/onboarding.ts +21 -2
- package/src/http/poll.ts +30 -3
- package/src/http/realtime.ts +62 -0
- package/src/http/request-principal.ts +22 -0
- package/src/http/status.ts +34 -2
- package/src/kv-reserved-namespaces.ts +10 -5
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +123 -0
- package/src/rbac/legacy-policy.ts +1 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/realtime/auth.ts +63 -1
- package/src/realtime/tickets.ts +52 -0
- package/src/realtime/transport.ts +51 -22
- package/src/scripts-runtime/types/swarm-extension.d.ts +3 -1
- package/src/telemetry-context.ts +175 -0
- package/src/telemetry-contract/EVENTS.md +81 -0
- package/src/telemetry-contract/SOURCE.json +9 -0
- package/src/telemetry-contract/catalog.json +1335 -0
- package/src/telemetry-contract/index.ts +60 -0
- package/src/telemetry-contract/types.gen.ts +1971 -0
- package/src/telemetry-identity.ts +129 -0
- package/src/telemetry-snapshot.ts +211 -0
- package/src/telemetry-trigger.ts +125 -0
- package/src/telemetry-types.typetest.ts +40 -0
- package/src/telemetry.ts +271 -67
- package/src/tests/api-drain-e2e.test.ts +249 -0
- package/src/tests/api-drain-worker-e2e.test.ts +216 -0
- package/src/tests/api-drain.test.ts +351 -0
- package/src/tests/comb-config-guard.test.ts +38 -0
- package/src/tests/comb-links.test.ts +162 -0
- package/src/tests/comb-markers.test.ts +68 -0
- package/src/tests/comb-review-batch.test.ts +770 -0
- package/src/tests/comb-status.test.ts +152 -0
- package/src/tests/db-task-read-contract.test.ts +2 -0
- package/src/tests/db-task-write-contract.test.ts +1 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/favorites-agent-fs-path.test.ts +226 -0
- package/src/tests/memory-link-resolver.test.ts +34 -0
- package/src/tests/onboarding.test.ts +108 -0
- package/src/tests/pi-mono-adapter.test.ts +265 -2
- package/src/tests/pr-body-leaks.test.ts +789 -0
- package/src/tests/preload.ts +8 -0
- package/src/tests/rbac-engine.test.ts +19 -0
- package/src/tests/realtime-room-auth.test.ts +104 -1
- package/src/tests/realtime-tickets.test.ts +74 -0
- package/src/tests/realtime-transport.test.ts +131 -4
- package/src/tests/runner-api-drain.test.ts +301 -0
- package/src/tests/runner-fallback-output.test.ts +61 -0
- package/src/tests/runner-fenced-json-output.test.ts +178 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/send-task-requested-by.test.ts +133 -15
- package/src/tests/task-lifecycle-telemetry.test.ts +8 -3
- package/src/tests/telemetry-context.test.ts +314 -0
- package/src/tests/telemetry-init.test.ts +51 -19
- package/src/tests/telemetry-snapshot.test.ts +229 -0
- package/src/tests/telemetry-trigger.test.ts +200 -0
- package/src/tests/template-registry-helpers.ts +1 -0
- package/src/tests/workflow-agent-task-retry.test.ts +363 -0
- package/src/tests/workflow-definition-telemetry.test.ts +4 -4
- package/src/tools/poll-task.ts +20 -1
- package/src/tools/send-task.ts +9 -1
- package/src/tools/task-action.ts +11 -0
- package/src/types.ts +2 -1
- package/src/utils/api-drain.ts +24 -0
- package/src/utils/constants.ts +57 -16
- package/src/utils/pr-body-leaks.ts +172 -0
- package/src/utils/task-attachment-links.ts +14 -8
- package/src/workflows/recovery.ts +26 -1
- package/src/workflows/resume.ts +31 -4
- package/src/workflows/task-step-routing.ts +75 -2
- package/tsconfig.json +3 -0
- package/dist/cli-tjf4cn8z.js +0 -9119
- package/dist/cli-tsk3nz8a.js +0 -582
- package/dist/{cli-t6v2ah3r.js → cli-twrh04ap.js} +3 -3
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { checkPrBodyLeaks, shouldCheckLeaks } from "../../scripts/check-pr-body";
|
|
4
|
+
import { codexPreToolUseBlock } from "../hooks/codex-hook";
|
|
5
|
+
import {
|
|
6
|
+
checkGhPrCommand,
|
|
7
|
+
guardGhPrBody,
|
|
8
|
+
type PrBodyGuardDeps,
|
|
9
|
+
parseGhPrCommands,
|
|
10
|
+
shellCommandOf,
|
|
11
|
+
} from "../hooks/pr-body-guard";
|
|
12
|
+
import { AGENT_FS_PATH_RULE_ENABLED, findPrBodyLeaks, LEAK_RULES } from "../utils/pr-body-leaks";
|
|
13
|
+
|
|
14
|
+
// Sentinels are assembled at runtime so this file never holds a real-looking
|
|
15
|
+
// identifier as a source literal.
|
|
16
|
+
const join = (...parts: string[]) => parts.join("");
|
|
17
|
+
const hexWithLetter = () => `a${randomUUID().replace(/-/g, "").slice(0, 7)}`;
|
|
18
|
+
const slackId = (kind: "C" | "D" | "U") => join(kind, "0", "AB12CD34E");
|
|
19
|
+
const slackTs = () => `${1_700_000_000 + 88_356_391}.${291_819}`;
|
|
20
|
+
const slackLink = () =>
|
|
21
|
+
join("https://acme.", "slack", ".com/", "archives/", slackId("C"), "/p", "1788356391291819");
|
|
22
|
+
const dashboardLink = () =>
|
|
23
|
+
join("https://", ["app", "agent-swarm", "dev"].join("."), "/tasks/", randomUUID());
|
|
24
|
+
const agentFsLink = () => join("https://", ["live", "agent-fs", "dev"].join("."), "/file/~/x/y");
|
|
25
|
+
const agentFsPath = () => join("thoughts", "/", randomUUID(), "/reports/r.md");
|
|
26
|
+
const presignedEmbed = () =>
|
|
27
|
+
join(
|
|
28
|
+
",
|
|
30
|
+
".png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=abc&X-Amz-Signature=",
|
|
31
|
+
"f".repeat(64),
|
|
32
|
+
")",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const NORMAL_BODY = `## Intent
|
|
36
|
+
|
|
37
|
+
A maintainer asked that the Slack handler retry on 429. Fixes #1234.
|
|
38
|
+
|
|
39
|
+
## Decisions & trade-offs
|
|
40
|
+
|
|
41
|
+
- The retry runs 3 times with backoff. Cost: a slow channel delays the reply by up to 7s.
|
|
42
|
+
|
|
43
|
+
## Proof of work
|
|
44
|
+
|
|
45
|
+
- \`bun run test:root -- src/tests/slack.test.ts\`: 42 pass, 0 fail.
|
|
46
|
+
- CI run 18234567890 passed on commit 3f2a9c1.
|
|
47
|
+
|
|
48
|
+
## Urgency
|
|
49
|
+
|
|
50
|
+
- [ ] asap
|
|
51
|
+
- [ ] this week
|
|
52
|
+
- [x] nice to have
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
describe("findPrBodyLeaks: positive controls", () => {
|
|
56
|
+
const cases: Array<[string, () => string]> = [
|
|
57
|
+
["slack-id", () => `Reported in channel ${slackId("C")} by ${slackId("U")}.`],
|
|
58
|
+
["slack-id", () => `DM ${slackId("D")}`],
|
|
59
|
+
["slack-ts", () => `thread ts ${slackTs()}`],
|
|
60
|
+
["slack-link", () => `See ${slackLink()}`],
|
|
61
|
+
["swarm-dashboard-link", () => `Run: ${dashboardLink()}`],
|
|
62
|
+
["agent-fs-link", () => `Report: ${agentFsLink()}`],
|
|
63
|
+
["swarm-task-ref", () => `Follow-up of task ${hexWithLetter()}.`],
|
|
64
|
+
["swarm-task-ref", () => `parentTaskId: \`${hexWithLetter()}-1111-2222-3333-444444444444\``],
|
|
65
|
+
["swarm-task-ref", () => `swarm run ${hexWithLetter()}`],
|
|
66
|
+
["private-chat-quote", () => `Taras in ${"DM"}: "it would be nice to explain the motivation"`],
|
|
67
|
+
[
|
|
68
|
+
"private-chat-quote",
|
|
69
|
+
() => `From the ${"Slack"} thread, Eze wrote “the deploy is stuck again today”`,
|
|
70
|
+
],
|
|
71
|
+
["private-chat-quote", () => `The ask, verbatim from ${"Slack"}:\n> please make it retry`],
|
|
72
|
+
];
|
|
73
|
+
for (const [category, build] of cases) {
|
|
74
|
+
test(`${category}: ${build().slice(0, 24)}...`, () => {
|
|
75
|
+
expect(findPrBodyLeaks(build())).toContain(category as never);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("agent-fs-path rule matches but stays off behind its constant", () => {
|
|
80
|
+
const rule = LEAK_RULES.find((r) => r.category === "agent-fs-path");
|
|
81
|
+
expect(rule?.pattern.test(`Durable copy: ${agentFsPath()}`)).toBe(true);
|
|
82
|
+
expect(AGENT_FS_PATH_RULE_ENABLED).toBe(false);
|
|
83
|
+
expect(rule?.enabled).toBe(false);
|
|
84
|
+
expect(findPrBodyLeaks(`Durable copies: ${agentFsPath()}`)).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("results name categories once, never the matched text", () => {
|
|
88
|
+
const id = slackId("C");
|
|
89
|
+
const leaks = findPrBodyLeaks(`${id} and ${slackId("U")} and ${slackTs()}`);
|
|
90
|
+
expect(leaks).toEqual(["slack-id", "slack-ts"]);
|
|
91
|
+
expect(checkPrBodyLeaks(`see ${id}`).join("\n")).not.toContain(id);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("findPrBodyLeaks: negative controls", () => {
|
|
96
|
+
test("a normal PR body", () => {
|
|
97
|
+
expect(findPrBodyLeaks(NORMAL_BODY)).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a code UUID in a fixture", () => {
|
|
101
|
+
const body = `\`\`\`ts\nconst agentId = "${randomUUID()}";\nconst run = { taskId: "${"0".repeat(8)}-0000-0000-0000-000000000000" };\n\`\`\``;
|
|
102
|
+
expect(findPrBodyLeaks(body)).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a fenced code example with a fresh UUID taskId", () => {
|
|
106
|
+
const body = `## Proof of work\n\n\`\`\`ts\nconst taskId = "${randomUUID()}";\nawait store({ parentTaskId: "${randomUUID()}" });\n\`\`\`\n`;
|
|
107
|
+
expect(findPrBodyLeaks(body)).toEqual([]);
|
|
108
|
+
// The same line outside the fence is still a ref.
|
|
109
|
+
expect(findPrBodyLeaks(`taskId = "${randomUUID()}"`)).toEqual(["swarm-task-ref"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a fence does not hide Slack or dashboard links", () => {
|
|
113
|
+
expect(findPrBodyLeaks(`\`\`\`\n${slackLink()}\n\`\`\`\n`)).toContain("slack-link");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a run label followed by an 8-char SHA", () => {
|
|
117
|
+
const sha = hexWithLetter();
|
|
118
|
+
expect(findPrBodyLeaks(`CI run ${sha} passed. Test run: ${sha}.`)).toEqual([]);
|
|
119
|
+
expect(findPrBodyLeaks(`workflow run ${sha}`)).toEqual(["swarm-task-ref"]);
|
|
120
|
+
expect(findPrBodyLeaks(`run_id: ${sha}`)).toEqual(["swarm-task-ref"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a commit SHA", () => {
|
|
124
|
+
const sha = randomUUID().replace(/-/g, "").slice(0, 40);
|
|
125
|
+
expect(findPrBodyLeaks(`Reverts ${sha} (commit ${sha.slice(0, 7)}), see #12.`)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a presigned QA embed", () => {
|
|
129
|
+
expect(findPrBodyLeaks(`## Proof of work\n\n${presignedEmbed()}\n`)).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("prose that mentions Slack or DMs without quoting anyone", () => {
|
|
133
|
+
const body =
|
|
134
|
+
'The Slack handler now returns "ok_with_retry_after" on 429. DM delivery is unchanged.';
|
|
135
|
+
expect(findPrBodyLeaks(body)).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("file names that contain slack, followed by a quote", () => {
|
|
139
|
+
const body = [
|
|
140
|
+
'`docs/integrations/slack.mdx`: all three said "Assistant View"; fixed to "Agent View".',
|
|
141
|
+
"`src/tests/slack-render-v2.test.ts:1480`, “resumes an unfinished outcome by thread,” fails.",
|
|
142
|
+
].join("\n");
|
|
143
|
+
expect(findPrBodyLeaks(body)).toEqual([]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("the repo PR template passes its own check", async () => {
|
|
147
|
+
const template = await Bun.file(".github/pull_request_template.md").text();
|
|
148
|
+
expect(findPrBodyLeaks(template)).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("check-pr-body leak gating", () => {
|
|
153
|
+
test("only bot authors, or a local run without an author, get the leak check", () => {
|
|
154
|
+
expect(shouldCheckLeaks(undefined)).toBe(true);
|
|
155
|
+
expect(shouldCheckLeaks("")).toBe(true);
|
|
156
|
+
expect(shouldCheckLeaks("desplega-bot")).toBe(true);
|
|
157
|
+
expect(shouldCheckLeaks("desplega-bot[bot]")).toBe(true);
|
|
158
|
+
expect(shouldCheckLeaks("octocat")).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
function fakeDeps(overrides: Partial<PrBodyGuardDeps> = {}) {
|
|
163
|
+
const calls = { visibility: 0, reads: [] as string[] };
|
|
164
|
+
const deps: PrBodyGuardDeps = {
|
|
165
|
+
env: { HOME: "/home/test" },
|
|
166
|
+
readFile: async (path) => {
|
|
167
|
+
calls.reads.push(path);
|
|
168
|
+
throw new Error("missing");
|
|
169
|
+
},
|
|
170
|
+
repoVisibility: async () => {
|
|
171
|
+
calls.visibility++;
|
|
172
|
+
return "PUBLIC";
|
|
173
|
+
},
|
|
174
|
+
...overrides,
|
|
175
|
+
};
|
|
176
|
+
return { deps, calls };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
describe("parseGhPrCommands", () => {
|
|
180
|
+
test("tracks cd, --repo and --body-file", () => {
|
|
181
|
+
const calls = parseGhPrCommands(
|
|
182
|
+
'cd /tmp/wt && gh pr create --title "t" --body-file body.md --repo o/r',
|
|
183
|
+
"/start",
|
|
184
|
+
);
|
|
185
|
+
expect(calls).toEqual([
|
|
186
|
+
{
|
|
187
|
+
cwd: "/tmp/wt",
|
|
188
|
+
repos: ["o/r"],
|
|
189
|
+
alsoCheckout: false,
|
|
190
|
+
unknownTarget: false,
|
|
191
|
+
texts: ["t"],
|
|
192
|
+
bodyFiles: ["body.md"],
|
|
193
|
+
unresolvedBodyFiles: [],
|
|
194
|
+
opaqueBody: false,
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("detects inline bodies, edit, and = forms; ignores other gh calls", () => {
|
|
200
|
+
expect(parseGhPrCommands("gh pr edit 12 -b 'x'", "/w")[0]?.texts).toEqual(["x"]);
|
|
201
|
+
expect(parseGhPrCommands("gh pr edit 12 --body-file=-", "/w")[0]?.opaqueBody).toBe(true);
|
|
202
|
+
expect(parseGhPrCommands("gh pr view 12 --json body", "/w")).toEqual([]);
|
|
203
|
+
expect(parseGhPrCommands("echo gh pr create", "/w")).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a heredoc, here-string or < file feeds --body-file -", () => {
|
|
207
|
+
const heredoc = parseGhPrCommands("gh pr create -F - <<'EOF'\nline one\nEOF\necho done", "/w");
|
|
208
|
+
expect(heredoc).toHaveLength(1);
|
|
209
|
+
expect(heredoc[0]?.texts).toEqual(["line one"]);
|
|
210
|
+
expect(parseGhPrCommands("gh pr create -F - <<< 'hi'", "/w")[0]?.texts).toEqual(["hi"]);
|
|
211
|
+
expect(parseGhPrCommands("gh pr create -F - < b.md", "/w")[0]?.bodyFiles).toEqual(["b.md"]);
|
|
212
|
+
expect(parseGhPrCommands("cat b.md | gh pr create -F -", "/w")[0]?.opaqueBody).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("checkGhPrCommand", () => {
|
|
217
|
+
const leakyInline = () => `gh pr create --title t --body "Context: ${slackLink()}"`;
|
|
218
|
+
|
|
219
|
+
test("a clean body is allowed without a visibility lookup", async () => {
|
|
220
|
+
const { deps, calls } = fakeDeps();
|
|
221
|
+
expect(
|
|
222
|
+
await checkGhPrCommand('gh pr create --title t --body "Fixes #1."', "/w", deps),
|
|
223
|
+
).toBeNull();
|
|
224
|
+
expect(calls.visibility).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a leaky inline body on a public repo is blocked by category only", async () => {
|
|
228
|
+
const { deps } = fakeDeps();
|
|
229
|
+
const command = leakyInline();
|
|
230
|
+
const reason = await checkGhPrCommand(command, "/w", deps);
|
|
231
|
+
expect(reason).toContain("slack-link");
|
|
232
|
+
expect(reason).not.toContain(slackId("C"));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a heredoc body is scanned", async () => {
|
|
236
|
+
const { deps } = fakeDeps();
|
|
237
|
+
const command = `gh pr create --title t --body "$(cat <<'EOF'\n## Intent\nFollow-up of task ${hexWithLetter()}, he said "hi".\nEOF\n)"`;
|
|
238
|
+
expect(await checkGhPrCommand(command, "/w", deps)).toContain("swarm-task-ref");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("private and internal repos are allowed", async () => {
|
|
242
|
+
for (const visibility of ["PRIVATE", "INTERNAL"]) {
|
|
243
|
+
const { deps } = fakeDeps({ repoVisibility: async () => visibility });
|
|
244
|
+
expect(await checkGhPrCommand(leakyInline(), "/w", deps)).toBeNull();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a failed visibility lookup fails closed when the body leaks", async () => {
|
|
249
|
+
const { deps } = fakeDeps({
|
|
250
|
+
repoVisibility: async () => {
|
|
251
|
+
throw new Error("gh: network down");
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
expect(await checkGhPrCommand(leakyInline(), "/w", deps)).toContain("could not be confirmed");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("--body-file is read relative to the cd target", async () => {
|
|
258
|
+
const { deps, calls } = fakeDeps({
|
|
259
|
+
readFile: async (path) => {
|
|
260
|
+
calls.reads.push(path);
|
|
261
|
+
return `Follow-up of task ${hexWithLetter()}`;
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const reason = await checkGhPrCommand(
|
|
265
|
+
"cd ~/wt && gh pr edit 7 --body-file notes/body.md",
|
|
266
|
+
"/w",
|
|
267
|
+
deps,
|
|
268
|
+
);
|
|
269
|
+
expect(calls.reads).toEqual(["/home/test/wt/notes/body.md"]);
|
|
270
|
+
expect(reason).toContain("swarm-task-ref");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("an unreadable --body-file fails closed on a public repo, open on a private one", async () => {
|
|
274
|
+
const command = "gh pr create -t t -F /nope/body.md";
|
|
275
|
+
expect(await checkGhPrCommand(command, "/w", fakeDeps().deps)).toContain("could not read");
|
|
276
|
+
const priv = fakeDeps({ repoVisibility: async () => "PRIVATE" });
|
|
277
|
+
expect(await checkGhPrCommand(command, "/w", priv.deps)).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("an unrelated shell comment does not block a clean body", async () => {
|
|
281
|
+
const { deps, calls } = fakeDeps();
|
|
282
|
+
const command = `# follow-up of task ${hexWithLetter()}\ngh pr create -t t --body "Fixes #1."`;
|
|
283
|
+
expect(await checkGhPrCommand(command, "/w", deps)).toBeNull();
|
|
284
|
+
expect(calls.visibility).toBe(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("an unquoted $(cat <<EOF) body falls back to scanning the whole command", async () => {
|
|
288
|
+
const { deps } = fakeDeps();
|
|
289
|
+
const command = `gh pr create -t t --body $(cat <<'EOF'\nSee ${slackLink()}\nEOF\n)`;
|
|
290
|
+
expect(await checkGhPrCommand(command, "/w", deps)).toContain("slack-link");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("an unexpanded shell variable body is allowed (fail open, CI is the backstop)", async () => {
|
|
294
|
+
const { deps, calls } = fakeDeps();
|
|
295
|
+
expect(await checkGhPrCommand('gh pr create -t t --body "$BODY"', "/w", deps)).toBeNull();
|
|
296
|
+
expect(calls.visibility).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Review finding 5: valid gh forms that bypassed the guard. The current checkout
|
|
301
|
+
// is PRIVATE; the real target (repo flag, PR URL or cd target) is PUBLIC.
|
|
302
|
+
describe("checkGhPrCommand: resolves the real target and body", () => {
|
|
303
|
+
const flagged = () => `Context: ${slackLink()}`;
|
|
304
|
+
const publicTarget = () => {
|
|
305
|
+
const seen: Array<{ cwd: string; repo?: string }> = [];
|
|
306
|
+
const { deps, calls } = fakeDeps({
|
|
307
|
+
readFile: async (path) => {
|
|
308
|
+
calls.reads.push(path);
|
|
309
|
+
return flagged();
|
|
310
|
+
},
|
|
311
|
+
repoVisibility: async (target) => {
|
|
312
|
+
seen.push(target);
|
|
313
|
+
const isPublic = target.repo ? target.repo === "public/example" : target.cwd === "/public";
|
|
314
|
+
return isPublic ? "PUBLIC" : "PRIVATE";
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
return { deps, calls, seen };
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
test("gh -R before pr", async () => {
|
|
321
|
+
const { deps } = publicTarget();
|
|
322
|
+
const command = `gh -R public/example pr create --body "${flagged()}"`;
|
|
323
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("attached -Rrepo", async () => {
|
|
327
|
+
const { deps } = publicTarget();
|
|
328
|
+
const command = `gh pr create -Rpublic/example --body "${flagged()}"`;
|
|
329
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("attached -R=repo", async () => {
|
|
333
|
+
const { deps } = publicTarget();
|
|
334
|
+
const command = `gh pr create -R=public/example --body "${flagged()}"`;
|
|
335
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('attached -b"body"', async () => {
|
|
339
|
+
const { deps } = publicTarget();
|
|
340
|
+
const command = `gh pr create -R public/example -b"${flagged()}"`;
|
|
341
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("attached -Fbody.md", async () => {
|
|
345
|
+
const { deps, calls } = publicTarget();
|
|
346
|
+
const command = "gh pr create -R public/example -Fbody.md";
|
|
347
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
348
|
+
expect(calls.reads).toEqual(["/private/body.md"]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("cd -- dir", async () => {
|
|
352
|
+
const { deps } = publicTarget();
|
|
353
|
+
const command = `cd -- /public && gh pr create --body "${flagged()}"`;
|
|
354
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("gh pr edit with a PR URL", async () => {
|
|
358
|
+
const { deps, seen } = publicTarget();
|
|
359
|
+
const command = `gh pr edit https://github.com/public/example/pull/1 --body "${flagged()}"`;
|
|
360
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
361
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("GH_REPO, as a prefix or exported", async () => {
|
|
365
|
+
for (const command of [
|
|
366
|
+
`GH_REPO=public/example gh pr create --body "${flagged()}"`,
|
|
367
|
+
`export GH_REPO=public/example && gh pr create --body "${flagged()}"`,
|
|
368
|
+
]) {
|
|
369
|
+
expect(await checkGhPrCommand(command, "/private", publicTarget().deps)).toContain(
|
|
370
|
+
"slack-link",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("an unresolvable cd target fails closed", async () => {
|
|
376
|
+
const { deps, seen } = publicTarget();
|
|
377
|
+
const command = `cd - && gh pr create --body "${flagged()}"`;
|
|
378
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("could not be confirmed");
|
|
379
|
+
expect(seen).toEqual([]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("forms that already blocked stay blocked", async () => {
|
|
383
|
+
for (const command of [
|
|
384
|
+
`cd /public && gh pr create --body "${flagged()}"`,
|
|
385
|
+
"cd /public && gh pr create --body-file body.md",
|
|
386
|
+
`cd /public && gh pr create -F - <<'EOF'\n${flagged()}\nEOF`,
|
|
387
|
+
]) {
|
|
388
|
+
expect(await checkGhPrCommand(command, "/private", publicTarget().deps)).toContain(
|
|
389
|
+
"slack-link",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("the same forms on a private target are allowed", async () => {
|
|
395
|
+
const { deps } = publicTarget();
|
|
396
|
+
const command = `gh -R private/example pr create -b"${flagged()}"`;
|
|
397
|
+
expect(await checkGhPrCommand(command, "/public", deps)).toBeNull();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// A `gh pr edit` target held in a variable must not fall back to the checkout.
|
|
401
|
+
const publicPrUrl = "https://github.com/public/example/pull/1";
|
|
402
|
+
|
|
403
|
+
test("gh pr edit with a PR URL variable resolved from the env", async () => {
|
|
404
|
+
for (const command of [
|
|
405
|
+
`gh pr edit "$PR_URL" --body "${flagged()}"`,
|
|
406
|
+
`gh pr edit "\${PR_URL}" --body "${flagged()}"`,
|
|
407
|
+
]) {
|
|
408
|
+
const { deps, seen } = publicTarget();
|
|
409
|
+
deps.env = { ...deps.env, PR_URL: publicPrUrl };
|
|
410
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
411
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("gh pr edit with a PR URL variable resolved from export", async () => {
|
|
416
|
+
const { deps, seen } = publicTarget();
|
|
417
|
+
const command = `export PR_URL=${publicPrUrl} && gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
418
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
419
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("an unresolvable gh pr edit target fails closed without a checkout lookup", async () => {
|
|
423
|
+
for (const command of [
|
|
424
|
+
`gh pr edit "$PR_URL" --body "${flagged()}"`,
|
|
425
|
+
`gh pr edit "$(gh pr list --json url -q '.[0].url')" --body "${flagged()}"`,
|
|
426
|
+
`gh pr edit $(gh pr list --json url -q '.[0].url') --body "${flagged()}"`,
|
|
427
|
+
`gh pr edit https://github.com/public/example/issues/1 --body "${flagged()}"`,
|
|
428
|
+
]) {
|
|
429
|
+
const { deps, seen } = publicTarget();
|
|
430
|
+
const reason = await checkGhPrCommand(command, "/private", deps);
|
|
431
|
+
expect(reason).toContain("could not be resolved");
|
|
432
|
+
expect(reason).toContain("slack-link");
|
|
433
|
+
expect(seen).toEqual([]);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("an unresolvable gh pr edit target with a clean body is allowed", async () => {
|
|
438
|
+
const { deps, seen } = publicTarget();
|
|
439
|
+
const command = 'gh pr edit "$PR_URL" --body "Fixes #1."';
|
|
440
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toBeNull();
|
|
441
|
+
expect(seen).toEqual([]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("PR number and branch targets still resolve in the checkout or -R repo", async () => {
|
|
445
|
+
for (const command of [
|
|
446
|
+
`gh pr edit 12 --body "${flagged()}"`,
|
|
447
|
+
`gh pr edit "#12" --body "${flagged()}"`,
|
|
448
|
+
`gh pr edit fix/some-branch --body "${flagged()}"`,
|
|
449
|
+
`gh pr edit "$BRANCH" --body "${flagged()}"`,
|
|
450
|
+
`gh pr edit --body "${flagged()}"`,
|
|
451
|
+
]) {
|
|
452
|
+
const priv = publicTarget();
|
|
453
|
+
priv.deps.env = { ...priv.deps.env, BRANCH: "fix/some-branch" };
|
|
454
|
+
expect(await checkGhPrCommand(command, "/private", priv.deps)).toBeNull();
|
|
455
|
+
expect(priv.seen).toEqual([{ cwd: "/private", repo: undefined }]);
|
|
456
|
+
|
|
457
|
+
const pub = publicTarget();
|
|
458
|
+
pub.deps.env = { ...pub.deps.env, BRANCH: "fix/some-branch" };
|
|
459
|
+
expect(await checkGhPrCommand(command, "/public", pub.deps)).toContain("slack-link");
|
|
460
|
+
}
|
|
461
|
+
const { deps, seen } = publicTarget();
|
|
462
|
+
const command = `gh pr edit 12 -R public/example --body "${flagged()}"`;
|
|
463
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
464
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// The parser's model of shell variables must not lag the shell. The hook env
|
|
468
|
+
// holds a stale local target; the command changes it before `gh` runs.
|
|
469
|
+
const staleLocal = () => {
|
|
470
|
+
const target = publicTarget();
|
|
471
|
+
target.deps.env = { ...target.deps.env, PR_URL: "fix/old-branch" };
|
|
472
|
+
return target;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
test("a plain assignment overrides a stale env target (flagged inline body)", async () => {
|
|
476
|
+
const { deps, seen } = staleLocal();
|
|
477
|
+
const command = `PR_URL=${publicPrUrl}; gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
478
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
479
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("a plain assignment overrides a stale env target (unreadable body file)", async () => {
|
|
483
|
+
const { deps, seen } = staleLocal();
|
|
484
|
+
deps.readFile = async () => {
|
|
485
|
+
throw new Error("missing");
|
|
486
|
+
};
|
|
487
|
+
const command = `PR_URL=${publicPrUrl}; gh pr edit "$PR_URL" --body-file /tmp/missing.md`;
|
|
488
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("could not read");
|
|
489
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("a plain assignment to a local target still resolves in the checkout", async () => {
|
|
493
|
+
const { deps, seen } = publicTarget();
|
|
494
|
+
deps.env = { ...deps.env, PR_URL: publicPrUrl };
|
|
495
|
+
const command = `PR_URL=fix/b; gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
496
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toBeNull();
|
|
497
|
+
expect(seen).toEqual([{ cwd: "/private", repo: undefined }]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("a prefix assignment on gh itself does not change the expanded target", async () => {
|
|
501
|
+
const { deps, seen } = publicTarget();
|
|
502
|
+
deps.env = { ...deps.env, PR_URL: publicPrUrl };
|
|
503
|
+
const command = `PR_URL=fix/b gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
504
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
505
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const expectUnresolved = async (commands: string[]) => {
|
|
509
|
+
for (const command of commands) {
|
|
510
|
+
const { deps, seen } = staleLocal();
|
|
511
|
+
const reason = await checkGhPrCommand(command, "/private", deps);
|
|
512
|
+
expect({ command, reason }).toEqual({
|
|
513
|
+
command,
|
|
514
|
+
reason: expect.stringContaining("could not be resolved"),
|
|
515
|
+
});
|
|
516
|
+
expect(seen).toEqual([]);
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
test("a non-literal assignment makes the target unknown", async () => {
|
|
521
|
+
const edit = `gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
522
|
+
await expectUnresolved([
|
|
523
|
+
`PR_URL=$(gh pr list --json url -q '.[0].url'); ${edit}`,
|
|
524
|
+
`PR_URL="$(gh pr list --json url -q '.[0].url')"; ${edit}`,
|
|
525
|
+
`PR_URL=\`gh pr list --json url -q .[0].url\`; ${edit}`,
|
|
526
|
+
`PR_URL=$OTHER; ${edit}`,
|
|
527
|
+
`export PR_URL=$(gh pr list --json url -q '.[0].url') && ${edit}`,
|
|
528
|
+
`PR_URL+=/x; ${edit}`,
|
|
529
|
+
]);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("read, source, eval, declare, for, unset and similar make a later target unknown", async () => {
|
|
533
|
+
const edit = `gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
534
|
+
await expectUnresolved([
|
|
535
|
+
`read -r PR_URL < /tmp/url; ${edit}`,
|
|
536
|
+
`source /tmp/env.sh; ${edit}`,
|
|
537
|
+
`. /tmp/env.sh; ${edit}`,
|
|
538
|
+
`eval "PR_URL=${publicPrUrl}"; ${edit}`,
|
|
539
|
+
`declare PR_URL=${publicPrUrl}; ${edit}`,
|
|
540
|
+
`typeset PR_URL=${publicPrUrl}; ${edit}`,
|
|
541
|
+
`local PR_URL=${publicPrUrl}; ${edit}`,
|
|
542
|
+
`readonly PR_URL=${publicPrUrl}; ${edit}`,
|
|
543
|
+
`mapfile -t PR_URL < /tmp/url; ${edit}`,
|
|
544
|
+
`readarray -t PR_URL < /tmp/url; ${edit}`,
|
|
545
|
+
`printf -v PR_URL '%s' ${publicPrUrl}; ${edit}`,
|
|
546
|
+
`while read -r PR_URL; do ${edit}; done < /tmp/urls`,
|
|
547
|
+
`for PR_URL in ${publicPrUrl}; do ${edit}; done`,
|
|
548
|
+
`unset PR_URL; ${edit}`,
|
|
549
|
+
]);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("an unknown target with a clean body is allowed without a lookup", async () => {
|
|
553
|
+
const { deps, seen } = staleLocal();
|
|
554
|
+
const command = 'read -r PR_URL < /tmp/url; gh pr edit "$PR_URL" --body "Fixes #1."';
|
|
555
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toBeNull();
|
|
556
|
+
expect(seen).toEqual([]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("subshell state does not leak out of ( ... )", async () => {
|
|
560
|
+
const { deps, seen } = publicTarget();
|
|
561
|
+
deps.env = { ...deps.env, PR_URL: publicPrUrl };
|
|
562
|
+
const edit = `(export PR_URL=fix/b); gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
563
|
+
expect(await checkGhPrCommand(edit, "/private", deps)).toContain("slack-link");
|
|
564
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
565
|
+
|
|
566
|
+
const create = publicTarget();
|
|
567
|
+
const command = `(cd /private); gh pr create --body "${flagged()}"`;
|
|
568
|
+
expect(await checkGhPrCommand(command, "/public", create.deps)).toContain("slack-link");
|
|
569
|
+
const inside = publicTarget();
|
|
570
|
+
const nested = `(cd /private && gh pr create --body "${flagged()}")`;
|
|
571
|
+
expect(await checkGhPrCommand(nested, "/public", inside.deps)).toBeNull();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("a body file path follows variables assigned in the command", async () => {
|
|
575
|
+
const reads: string[] = [];
|
|
576
|
+
const { deps } = publicTarget();
|
|
577
|
+
deps.env = { ...deps.env, F: "/tmp/clean.md" };
|
|
578
|
+
deps.readFile = async (path) => {
|
|
579
|
+
reads.push(path);
|
|
580
|
+
return path === "/tmp/leaky.md" ? flagged() : "Fixes #1.";
|
|
581
|
+
};
|
|
582
|
+
const command = 'F=/tmp/leaky.md; gh pr create --body-file "$F"';
|
|
583
|
+
expect(await checkGhPrCommand(command, "/public", deps)).toContain("slack-link");
|
|
584
|
+
expect(reads).toEqual(["/tmp/leaky.md"]);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("an unexported GH_REPO assignment also checks the checkout", async () => {
|
|
588
|
+
const { deps, seen } = publicTarget();
|
|
589
|
+
const command = `GH_REPO=private/example; gh pr create --body "${flagged()}"`;
|
|
590
|
+
expect(await checkGhPrCommand(command, "/public", deps)).toContain("slack-link");
|
|
591
|
+
expect(seen).toEqual([
|
|
592
|
+
{ cwd: "/public", repo: "private/example" },
|
|
593
|
+
{ cwd: "/public", repo: undefined },
|
|
594
|
+
]);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("gh after a reserved word (then, do) is still checked", async () => {
|
|
598
|
+
for (const command of [
|
|
599
|
+
`if true; then gh pr create --body "${flagged()}"; fi`,
|
|
600
|
+
`for i in 1; do gh pr create --body "${flagged()}"; done`,
|
|
601
|
+
]) {
|
|
602
|
+
const { deps } = publicTarget();
|
|
603
|
+
expect(await checkGhPrCommand(command, "/public", deps)).toContain("slack-link");
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("gh inside bash -c or eval is checked", async () => {
|
|
608
|
+
for (const command of [
|
|
609
|
+
`bash -c 'gh pr create --body "${flagged()}"'`,
|
|
610
|
+
`sh -lc 'cd /public && gh pr create --body "${flagged()}"'`,
|
|
611
|
+
`eval 'gh pr create --body "${flagged()}"'`,
|
|
612
|
+
]) {
|
|
613
|
+
const { deps } = publicTarget();
|
|
614
|
+
expect({ command, reason: await checkGhPrCommand(command, "/public", deps) }).toEqual({
|
|
615
|
+
command,
|
|
616
|
+
reason: expect.stringContaining("slack-link"),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
// The child shell sees exported variables and prefix assignments only.
|
|
620
|
+
const { deps, seen } = staleLocal();
|
|
621
|
+
const command = `PR_URL=${publicPrUrl} bash -c 'gh pr edit "$PR_URL" --body "${flagged()}"'`;
|
|
622
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
623
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
624
|
+
const local = publicTarget();
|
|
625
|
+
const unexported = `PR_URL=fix/b; bash -c 'gh pr edit "$PR_URL" --body "${flagged()}"'`;
|
|
626
|
+
expect(await checkGhPrCommand(unexported, "/private", local.deps)).toContain(
|
|
627
|
+
"could not be resolved",
|
|
628
|
+
);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
test("a wrapper with flags makes the cwd and GH_REPO unknown", async () => {
|
|
632
|
+
const { deps, seen } = publicTarget();
|
|
633
|
+
deps.env = { ...deps.env, GH_REPO: "private/example" };
|
|
634
|
+
const command = `env -u GH_REPO gh pr create --body "${flagged()}"`;
|
|
635
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("could not be resolved");
|
|
636
|
+
expect(seen).toEqual([]);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// The parser cannot tell whether a conditional write ran. The hook env holds
|
|
640
|
+
// the public target; each command may or may not move it to a local branch.
|
|
641
|
+
const publicEnvTarget = () => {
|
|
642
|
+
const target = publicTarget();
|
|
643
|
+
target.deps.env = { ...target.deps.env, PR_URL: publicPrUrl };
|
|
644
|
+
return target;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
test("a skipped conditional assignment makes the target unknown", async () => {
|
|
648
|
+
const edit = `gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
649
|
+
for (const command of [
|
|
650
|
+
`if false; then PR_URL=fix/private; fi\n${edit}`,
|
|
651
|
+
`false && PR_URL=fix/private; ${edit}`,
|
|
652
|
+
`true || PR_URL=fix/private; ${edit}`,
|
|
653
|
+
`false &&\n PR_URL=fix/private; ${edit}`,
|
|
654
|
+
`false || PR_URL=fix/private && ${edit}`,
|
|
655
|
+
`if c; then PR_URL=fix/private; else ${edit}; fi`,
|
|
656
|
+
`if c; then PR_URL=fix/a; PR_URL=fix/private; fi; ${edit}`,
|
|
657
|
+
`while false; do ${edit}; PR_URL=fix/private; done`,
|
|
658
|
+
`PR_URL=fix/private && true & ${edit}`,
|
|
659
|
+
`f() { PR_URL=fix/private; }; f; ${edit}`,
|
|
660
|
+
]) {
|
|
661
|
+
const { deps, seen } = publicEnvTarget();
|
|
662
|
+
const reason = await checkGhPrCommand(command, "/private", deps);
|
|
663
|
+
expect({ command, reason }).toEqual({
|
|
664
|
+
command,
|
|
665
|
+
reason: expect.stringContaining("could not be resolved"),
|
|
666
|
+
});
|
|
667
|
+
expect(seen).toEqual([]);
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("a skipped assignment with an unreadable body file blocks; a clean body allows", async () => {
|
|
672
|
+
const { deps, seen } = publicEnvTarget();
|
|
673
|
+
deps.readFile = async () => {
|
|
674
|
+
throw new Error("missing");
|
|
675
|
+
};
|
|
676
|
+
const unreadable = 'false && PR_URL=fix/private; gh pr edit "$PR_URL" --body-file /tmp/x.md';
|
|
677
|
+
expect(await checkGhPrCommand(unreadable, "/private", deps)).toContain("could not read");
|
|
678
|
+
expect(seen).toEqual([]);
|
|
679
|
+
|
|
680
|
+
const clean = publicEnvTarget();
|
|
681
|
+
const command = 'false && PR_URL=fix/private; gh pr edit "$PR_URL" --body "Fixes #1."';
|
|
682
|
+
expect(await checkGhPrCommand(command, "/private", clean.deps)).toBeNull();
|
|
683
|
+
expect(clean.seen).toEqual([]);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("an assignment in a subshell or pipeline does not persist", async () => {
|
|
687
|
+
const edit = `gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
688
|
+
for (const command of [
|
|
689
|
+
`{ PR_URL=fix/private; } | cat; ${edit}`,
|
|
690
|
+
`(false && PR_URL=fix/private; ${edit})`,
|
|
691
|
+
]) {
|
|
692
|
+
const { deps, seen } = publicEnvTarget();
|
|
693
|
+
const reason = await checkGhPrCommand(command, "/private", deps);
|
|
694
|
+
expect({ command, reason }).toEqual({
|
|
695
|
+
command,
|
|
696
|
+
reason: expect.stringContaining("could not be resolved"),
|
|
697
|
+
});
|
|
698
|
+
expect(seen).toEqual([]);
|
|
699
|
+
}
|
|
700
|
+
// Leaving `( ... )` restores the env value: the public target.
|
|
701
|
+
const { deps, seen } = publicEnvTarget();
|
|
702
|
+
const command = `(PR_URL=fix/private); ${edit}`;
|
|
703
|
+
expect(await checkGhPrCommand(command, "/private", deps)).toContain("slack-link");
|
|
704
|
+
expect(seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("a skipped cd makes the checkout unknown", async () => {
|
|
708
|
+
for (const command of [
|
|
709
|
+
`false && cd /private; gh pr create --body "${flagged()}"`,
|
|
710
|
+
`if false; then cd /private; fi; gh pr create --body "${flagged()}"`,
|
|
711
|
+
`cd /elsewhere || cd /private; gh pr create --body "${flagged()}"`,
|
|
712
|
+
]) {
|
|
713
|
+
const { deps, seen } = publicTarget();
|
|
714
|
+
const reason = await checkGhPrCommand(command, "/public", deps);
|
|
715
|
+
expect({ command, reason }).toEqual({
|
|
716
|
+
command,
|
|
717
|
+
reason: expect.stringContaining("could not be confirmed"),
|
|
718
|
+
});
|
|
719
|
+
expect(seen).toEqual([]);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("a write still counts where it must have run", async () => {
|
|
724
|
+
const { deps, seen } = publicEnvTarget();
|
|
725
|
+
const inBranch = `if c; then PR_URL=fix/b; gh pr edit "$PR_URL" --body "${flagged()}"; fi`;
|
|
726
|
+
expect(await checkGhPrCommand(inBranch, "/private", deps)).toBeNull();
|
|
727
|
+
expect(seen).toEqual([{ cwd: "/private", repo: undefined }]);
|
|
728
|
+
|
|
729
|
+
const chain = publicEnvTarget();
|
|
730
|
+
const andChain = `git fetch && PR_URL=fix/b && gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
731
|
+
expect(await checkGhPrCommand(andChain, "/private", chain.deps)).toBeNull();
|
|
732
|
+
|
|
733
|
+
const after = publicEnvTarget();
|
|
734
|
+
const overwritten = `if false; then PR_URL=fix/a; fi; PR_URL=${publicPrUrl}; gh pr edit "$PR_URL" --body "${flagged()}"`;
|
|
735
|
+
expect(await checkGhPrCommand(overwritten, "/private", after.deps)).toContain("slack-link");
|
|
736
|
+
expect(after.seen.map((t) => t.repo)).toEqual(["public/example"]);
|
|
737
|
+
|
|
738
|
+
const cd = publicTarget();
|
|
739
|
+
const command = "git fetch && cd /private && gh pr create --body-file b.md";
|
|
740
|
+
expect(await checkGhPrCommand(command, "/public", cd.deps)).toBeNull();
|
|
741
|
+
expect(cd.calls.reads).toEqual(["/private/b.md"]);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("clean body files are allowed in public and private checkouts", async () => {
|
|
745
|
+
for (const command of [
|
|
746
|
+
"gh pr create --title t --body-file /tmp/pr-body.md",
|
|
747
|
+
"gh pr edit 1 --body-file f",
|
|
748
|
+
]) {
|
|
749
|
+
for (const cwd of ["/public", "/private"]) {
|
|
750
|
+
const { deps, seen } = publicTarget();
|
|
751
|
+
deps.readFile = async () => NORMAL_BODY;
|
|
752
|
+
expect(await checkGhPrCommand(command, cwd, deps)).toBeNull();
|
|
753
|
+
expect(seen).toEqual([]);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
describe("hook entry points", () => {
|
|
760
|
+
test("shellCommandOf reads strings and argv arrays", () => {
|
|
761
|
+
expect(shellCommandOf({ command: "gh pr list" })).toBe("gh pr list");
|
|
762
|
+
expect(shellCommandOf({ command: ["bash", "-lc", "gh pr create"] })).toBe("gh pr create");
|
|
763
|
+
expect(shellCommandOf({ command: ["gh", "pr", "create"] })).toBe("gh pr create");
|
|
764
|
+
expect(shellCommandOf({ file_path: "/x" })).toBeNull();
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("guardGhPrBody ignores non-shell input and fails closed when the lookup throws", async () => {
|
|
768
|
+
const { deps } = fakeDeps({
|
|
769
|
+
repoVisibility: () => {
|
|
770
|
+
throw new Error("sync throw");
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
expect(await guardGhPrBody(undefined, "/w", deps)).toBeNull();
|
|
774
|
+
expect(
|
|
775
|
+
await guardGhPrBody({ command: `gh pr create --body "${slackTs()}"` }, "/w", deps),
|
|
776
|
+
).toContain("slack-ts");
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
test("codex PreToolUse blocks a leaky gh pr create; other events pass", async () => {
|
|
780
|
+
const { deps } = fakeDeps();
|
|
781
|
+
const tool_input = { command: ["bash", "-lc", `gh pr create --body "${dashboardLink()}"`] };
|
|
782
|
+
expect(
|
|
783
|
+
await codexPreToolUseBlock({ hook_event_name: "PreToolUse", tool_input, cwd: "/w" }, deps),
|
|
784
|
+
).toContain("swarm-dashboard-link");
|
|
785
|
+
expect(
|
|
786
|
+
await codexPreToolUseBlock({ hook_event_name: "PostToolUse", tool_input, cwd: "/w" }, deps),
|
|
787
|
+
).toBeNull();
|
|
788
|
+
});
|
|
789
|
+
});
|