@boyingliu01/xp-gate 0.18.5 → 0.19.1

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.
Files changed (254) hide show
  1. package/adapter-common.sh +27 -0
  2. package/adapters/gate-3.sh +0 -0
  3. package/adapters/gate-4.sh +0 -0
  4. package/adapters/gate-7.sh +0 -0
  5. package/adapters/gate-8.sh +0 -0
  6. package/adapters/gate-9.sh +0 -0
  7. package/adapters/powershell.sh +114 -35
  8. package/adapters/typescript.sh +8 -8
  9. package/build-integrity/__tests__/import-resolver.test.ts +22 -0
  10. package/build-integrity/__tests__/orchestrator.test.ts +1 -2
  11. package/build-integrity/gate-10.ts +7 -0
  12. package/gate-12-file-hygiene.sh +230 -0
  13. package/hooks/adapter-common.sh +27 -0
  14. package/hooks/gate-10.sh +105 -0
  15. package/hooks/gate-12-file-hygiene.sh +0 -0
  16. package/hooks/lib/validate-code-walkthrough.cjs +125 -0
  17. package/hooks/pre-commit +130 -33
  18. package/hooks/pre-push +15 -91
  19. package/lib/__tests__/check-version.test.js +67 -46
  20. package/lib/__tests__/doctor.test.js +7 -6
  21. package/lib/__tests__/gate-runner.test.js +267 -0
  22. package/lib/__tests__/init.test.js +32 -0
  23. package/lib/__tests__/npm-package.test.js +13 -0
  24. package/lib/__tests__/phase-transition.test.js +604 -12
  25. package/lib/__tests__/sprint-discovery.test.ts +0 -1
  26. package/lib/__tests__/sprint-status-rework.test.js +100 -1
  27. package/lib/__tests__/update-hooks.test.js +143 -10
  28. package/lib/__tests__/upgrade-exec.test.js +73 -45
  29. package/lib/__tests__/upgrade-skill-sync.test.js +1 -1
  30. package/lib/__tests__/upgrade.test.js +12 -11
  31. package/lib/doctor.js +2 -1
  32. package/lib/gate-runner.js +68 -23
  33. package/lib/init.js +34 -5
  34. package/lib/install-skill.js +1 -0
  35. package/lib/mattpocock-license-verdict.json +1 -1
  36. package/lib/phase-transition.js +203 -23
  37. package/lib/shared-paths.js +2 -0
  38. package/lib/sprint-status.js +15 -4
  39. package/lib/test-alignment.ts +3 -13
  40. package/lib/update-hooks.js +129 -15
  41. package/mock-policy/AGENTS.md +4 -4
  42. package/mock-policy/__tests__/gate-m3.test.ts +0 -5
  43. package/mock-policy/__tests__/scope-scanner.test.ts +8 -2
  44. package/mutation/AGENTS.md +3 -3
  45. package/mutation/__tests__/mutmut-runner.test.ts +443 -2
  46. package/mutation/__tests__/update-baseline.test.ts +0 -2
  47. package/mutation/runners/mutmut-runner.ts +67 -17
  48. package/package.json +9 -3
  49. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  50. package/plugins/claude-code/skills/clipboard-vision/SKILL.md +47 -0
  51. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +65 -0
  52. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +185 -0
  53. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +138 -0
  54. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +273 -0
  55. package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.ps1 +115 -0
  56. package/plugins/claude-code/skills/clipboard-vision/describe-clipboard.sh +161 -0
  57. package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.ps1 +86 -0
  58. package/plugins/claude-code/skills/clipboard-vision/scripts/clipboard-vision.sh +137 -0
  59. package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +24 -27
  60. package/plugins/claude-code/skills/delphi-review/AGENTS.md +12 -11
  61. package/plugins/claude-code/skills/delphi-review/INSTALL.md +15 -17
  62. package/plugins/claude-code/skills/delphi-review/SKILL.md +30 -29
  63. package/plugins/claude-code/skills/delphi-review/opencode.json.delphi.example +6 -10
  64. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +33 -49
  65. package/plugins/claude-code/skills/delphi-review/references/requirements.md +66 -54
  66. package/plugins/claude-code/skills/delphi-review/references/round-templates.md +7 -7
  67. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  68. package/plugins/claude-code/skills/sprint-flow/SKILL.md +4 -4
  69. package/plugins/claude-code/skills/sprint-flow/references/components/skill-invocations.md +2 -2
  70. package/plugins/claude-code/skills/sprint-flow/references/force-levels.md +37 -35
  71. package/plugins/claude-code/skills/sprint-flow/references/orchestration-rules.md +1 -1
  72. package/plugins/claude-code/skills/sprint-flow/references/phase-1-prep.md +1 -1
  73. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +24 -14
  74. package/plugins/claude-code/skills/sprint-flow/references/phase-4-verify.md +6 -6
  75. package/plugins/claude-code/skills/sprint-flow/references/phase-overview.md +2 -2
  76. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  77. package/plugins/dsh/README.md +25 -0
  78. package/plugins/dsh/cordis.patch.yml +7 -0
  79. package/plugins/dsh/package.json +70 -0
  80. package/plugins/dsh/scripts/prepack.cjs +82 -0
  81. package/plugins/dsh/skills/admin-template-guidelines/SKILL.md +113 -0
  82. package/plugins/dsh/skills/admin-template-guidelines/references/rule-1.md +91 -0
  83. package/plugins/dsh/skills/admin-template-guidelines/references/rule-2.md +155 -0
  84. package/plugins/dsh/skills/admin-template-guidelines/references/rule-3.md +70 -0
  85. package/plugins/dsh/skills/admin-template-guidelines/references/rule-4.md +154 -0
  86. package/plugins/dsh/skills/admin-template-guidelines/references/rule-5.md +126 -0
  87. package/plugins/dsh/skills/admin-template-guidelines/references/rule-6.md +180 -0
  88. package/plugins/dsh/skills/batch-grill-me/SKILL.md +15 -0
  89. package/plugins/dsh/skills/clipboard-vision/SKILL.md +47 -0
  90. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +65 -0
  91. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +185 -0
  92. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +138 -0
  93. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +273 -0
  94. package/plugins/dsh/skills/clipboard-vision/describe-clipboard.ps1 +115 -0
  95. package/plugins/dsh/skills/clipboard-vision/describe-clipboard.sh +161 -0
  96. package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.ps1 +86 -0
  97. package/plugins/dsh/skills/clipboard-vision/scripts/clipboard-vision.sh +137 -0
  98. package/plugins/dsh/skills/delphi-review/.delphi-config.json.example +70 -0
  99. package/plugins/dsh/skills/delphi-review/AGENTS.md +67 -0
  100. package/plugins/dsh/skills/delphi-review/INSTALL.md +150 -0
  101. package/plugins/dsh/skills/delphi-review/SKILL.md +559 -0
  102. package/plugins/dsh/skills/delphi-review/hooks.md +19 -0
  103. package/plugins/dsh/skills/delphi-review/opencode.json.delphi.example +52 -0
  104. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +470 -0
  105. package/plugins/dsh/skills/delphi-review/references/orchestrator-dispatch.md +64 -0
  106. package/plugins/dsh/skills/delphi-review/references/requirements.md +402 -0
  107. package/plugins/dsh/skills/delphi-review/references/round-templates.md +134 -0
  108. package/plugins/dsh/skills/delphi-review/scripts/verify-consensus.sh +43 -0
  109. package/plugins/dsh/skills/delphi-review/tools.md +29 -0
  110. package/plugins/dsh/skills/domain-modeling/ADR-FORMAT.md +64 -0
  111. package/plugins/dsh/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  112. package/plugins/dsh/skills/domain-modeling/SKILL.md +74 -0
  113. package/plugins/dsh/skills/grill-with-docs/SKILL.md +7 -0
  114. package/plugins/dsh/skills/grilling/SKILL.md +12 -0
  115. package/plugins/dsh/skills/improve-codebase-architecture/SKILL.md +156 -0
  116. package/plugins/dsh/skills/ralph-loop/SKILL.md +198 -0
  117. package/plugins/dsh/skills/ralph-loop/references/components.md +113 -0
  118. package/plugins/dsh/skills/ralph-loop/references/phase-2-build-ralph.md +89 -0
  119. package/plugins/dsh/skills/ralph-loop/references/workflow-details.md +99 -0
  120. package/plugins/dsh/skills/ralph-loop/templates/progress-log.md +36 -0
  121. package/plugins/dsh/skills/sprint-flow/AGENTS.md +103 -0
  122. package/plugins/dsh/skills/sprint-flow/SKILL.md +411 -0
  123. package/plugins/dsh/skills/sprint-flow/__tests__/sprint-flow.test.cjs +457 -0
  124. package/plugins/dsh/skills/sprint-flow/hooks.md +18 -0
  125. package/plugins/dsh/skills/sprint-flow/references/components/memory.md +87 -0
  126. package/plugins/dsh/skills/sprint-flow/references/components/middleware.md +75 -0
  127. package/plugins/dsh/skills/sprint-flow/references/components/skill-invocations.md +105 -0
  128. package/plugins/dsh/skills/sprint-flow/references/components/system-prompt.md +27 -0
  129. package/plugins/dsh/skills/sprint-flow/references/components/tool-descriptions.md +95 -0
  130. package/plugins/dsh/skills/sprint-flow/references/force-levels.md +204 -0
  131. package/plugins/dsh/skills/sprint-flow/references/orchestration-rules.md +236 -0
  132. package/plugins/dsh/skills/sprint-flow/references/phase-1-prep.md +294 -0
  133. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +273 -0
  134. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +348 -0
  135. package/plugins/dsh/skills/sprint-flow/references/phase-4-verify.md +225 -0
  136. package/plugins/dsh/skills/sprint-flow/references/phase-5-ship.md +222 -0
  137. package/plugins/dsh/skills/sprint-flow/references/phase-6-close.md +266 -0
  138. package/plugins/dsh/skills/sprint-flow/references/phase-overview.md +486 -0
  139. package/plugins/dsh/skills/sprint-flow/scripts/verify-sprint-state.sh +38 -0
  140. package/plugins/dsh/skills/sprint-flow/templates/auto-estimate-learning-log.md +136 -0
  141. package/plugins/dsh/skills/sprint-flow/templates/auto-estimate-output-template.md +110 -0
  142. package/plugins/dsh/skills/sprint-flow/templates/emergent-issues-template.md +120 -0
  143. package/plugins/dsh/skills/sprint-flow/templates/pain-document-template.md +115 -0
  144. package/plugins/dsh/skills/sprint-flow/templates/sprint-progress-template.md +140 -0
  145. package/plugins/dsh/skills/sprint-flow/templates/sprint-summary-template.md +121 -0
  146. package/plugins/dsh/skills/sprint-flow/tools.md +29 -0
  147. package/plugins/dsh/skills/test-driven-development/SKILL.md +371 -0
  148. package/plugins/dsh/skills/test-driven-development/testing-anti-patterns.md +299 -0
  149. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +56 -0
  150. package/plugins/dsh/skills/test-specification-alignment/SKILL.md +205 -0
  151. package/plugins/dsh/skills/test-specification-alignment/references/alignment-verification-algorithm.md +493 -0
  152. package/plugins/dsh/skills/test-specification-alignment/references/phase2-constraint-enforcement.md +431 -0
  153. package/plugins/dsh/skills/test-specification-alignment/references/specification-format.md +348 -0
  154. package/plugins/dsh/skills/to-issues/SKILL.md +277 -0
  155. package/plugins/dsh/src/command.test.ts +102 -0
  156. package/plugins/dsh/src/command.ts +75 -0
  157. package/plugins/dsh/src/contract.test.ts +62 -0
  158. package/plugins/dsh/src/gate-runner.test.ts +100 -0
  159. package/plugins/dsh/src/gate-runner.ts +58 -0
  160. package/plugins/dsh/src/index.test.ts +97 -0
  161. package/plugins/dsh/src/index.ts +94 -0
  162. package/plugins/dsh/src/skills.test.ts +58 -0
  163. package/plugins/dsh/tsconfig.build.json +13 -0
  164. package/plugins/dsh/tsconfig.json +19 -0
  165. package/plugins/dsh/vitest.config.ts +8 -0
  166. package/plugins/opencode/package.json +1 -1
  167. package/plugins/opencode/scripts/prepack.cjs +1 -0
  168. package/plugins/opencode/skills/clipboard-vision/SKILL.md +47 -0
  169. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +65 -0
  170. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +185 -0
  171. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +138 -0
  172. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +273 -0
  173. package/plugins/opencode/skills/clipboard-vision/describe-clipboard.ps1 +115 -0
  174. package/plugins/opencode/skills/clipboard-vision/describe-clipboard.sh +161 -0
  175. package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.ps1 +86 -0
  176. package/plugins/opencode/skills/clipboard-vision/scripts/clipboard-vision.sh +137 -0
  177. package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +24 -27
  178. package/plugins/opencode/skills/delphi-review/AGENTS.md +12 -11
  179. package/plugins/opencode/skills/delphi-review/INSTALL.md +15 -17
  180. package/plugins/opencode/skills/delphi-review/SKILL.md +30 -29
  181. package/plugins/opencode/skills/delphi-review/opencode.json.delphi.example +6 -10
  182. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +33 -49
  183. package/plugins/opencode/skills/delphi-review/references/requirements.md +66 -54
  184. package/plugins/opencode/skills/delphi-review/references/round-templates.md +7 -7
  185. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  186. package/plugins/opencode/skills/sprint-flow/SKILL.md +4 -4
  187. package/plugins/opencode/skills/sprint-flow/references/components/skill-invocations.md +2 -2
  188. package/plugins/opencode/skills/sprint-flow/references/force-levels.md +37 -35
  189. package/plugins/opencode/skills/sprint-flow/references/orchestration-rules.md +1 -1
  190. package/plugins/opencode/skills/sprint-flow/references/phase-1-prep.md +1 -1
  191. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +24 -14
  192. package/plugins/opencode/skills/sprint-flow/references/phase-4-verify.md +6 -6
  193. package/plugins/opencode/skills/sprint-flow/references/phase-overview.md +2 -2
  194. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  195. package/plugins/qoder/plugin.json +1 -1
  196. package/plugins/qoder/skills/clipboard-vision/SKILL.md +47 -0
  197. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +65 -0
  198. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +185 -0
  199. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +138 -0
  200. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +273 -0
  201. package/plugins/qoder/skills/clipboard-vision/clipboard-vision/SKILL.md +47 -0
  202. package/plugins/qoder/skills/clipboard-vision/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +65 -0
  203. package/plugins/qoder/skills/clipboard-vision/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +185 -0
  204. package/plugins/qoder/skills/clipboard-vision/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +138 -0
  205. package/plugins/qoder/skills/clipboard-vision/clipboard-vision/__tests__/clipboard-vision.test.cjs +273 -0
  206. package/plugins/qoder/skills/clipboard-vision/clipboard-vision/scripts/clipboard-vision.ps1 +86 -0
  207. package/plugins/qoder/skills/clipboard-vision/clipboard-vision/scripts/clipboard-vision.sh +137 -0
  208. package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.ps1 +86 -0
  209. package/plugins/qoder/skills/clipboard-vision/scripts/clipboard-vision.sh +137 -0
  210. package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +3 -11
  211. package/plugins/qoder/skills/delphi-review/AGENTS.md +14 -16
  212. package/plugins/qoder/skills/delphi-review/INSTALL.md +12 -12
  213. package/plugins/qoder/skills/delphi-review/SKILL.md +58 -25
  214. package/plugins/qoder/skills/delphi-review/opencode.json.delphi.example +1 -1
  215. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +33 -49
  216. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  217. package/plugins/qoder/skills/sprint-flow/SKILL.md +2 -2
  218. package/plugins/qoder/skills/sprint-flow/references/force-levels.md +38 -35
  219. package/plugins/qoder/skills/sprint-flow/references/phase-1-prep.md +1 -1
  220. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +48 -6
  221. package/plugins/qoder/skills/sprint-flow/references/phase-4-verify.md +5 -6
  222. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  223. package/principles/AGENTS.md +3 -3
  224. package/scripts/__tests__/sync-package-content.test.js +30 -0
  225. package/scripts/delphi-external-review.cjs +194 -63
  226. package/scripts/sync-package-content.js +9 -2
  227. package/skills/clipboard-vision/SKILL.md +47 -0
  228. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +65 -0
  229. package/skills/clipboard-vision/__tests__/clipboard-vision-security.test.cjs +185 -0
  230. package/skills/clipboard-vision/__tests__/clipboard-vision.Tests.ps1 +138 -0
  231. package/skills/clipboard-vision/__tests__/clipboard-vision.test.cjs +273 -0
  232. package/skills/clipboard-vision/describe-clipboard.ps1 +115 -0
  233. package/skills/clipboard-vision/describe-clipboard.sh +161 -0
  234. package/skills/clipboard-vision/scripts/clipboard-vision.ps1 +86 -0
  235. package/skills/clipboard-vision/scripts/clipboard-vision.sh +137 -0
  236. package/skills/delphi-review/.delphi-config.json.example +24 -27
  237. package/skills/delphi-review/AGENTS.md +12 -11
  238. package/skills/delphi-review/INSTALL.md +15 -17
  239. package/skills/delphi-review/SKILL.md +30 -29
  240. package/skills/delphi-review/opencode.json.delphi.example +6 -10
  241. package/skills/delphi-review/references/code-walkthrough.md +33 -49
  242. package/skills/delphi-review/references/requirements.md +66 -54
  243. package/skills/delphi-review/references/round-templates.md +7 -7
  244. package/skills/sprint-flow/AGENTS.md +4 -4
  245. package/skills/sprint-flow/SKILL.md +4 -4
  246. package/skills/sprint-flow/references/components/skill-invocations.md +2 -2
  247. package/skills/sprint-flow/references/force-levels.md +37 -35
  248. package/skills/sprint-flow/references/orchestration-rules.md +1 -1
  249. package/skills/sprint-flow/references/phase-1-prep.md +1 -1
  250. package/skills/sprint-flow/references/phase-2-design.md +24 -14
  251. package/skills/sprint-flow/references/phase-4-verify.md +6 -6
  252. package/skills/sprint-flow/references/phase-overview.md +2 -2
  253. package/skills/test-specification-alignment/AGENTS.md +3 -3
  254. package/sprint-gate.sh +291 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @test REQ-379 clipboard vision security hardening
3
+ * @intent Verify credential transport, endpoint-bound consent, literal loopback trust, and output no-follow behavior
4
+ * @covers AC-379-02, AC-379-03, AC-379-04, AC-379-06
5
+ */
6
+ const crypto = require('crypto');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+
12
+ const ROOT = path.resolve(__dirname, '..');
13
+ const SCRIPT = path.join(ROOT, 'scripts', 'clipboard-vision.sh');
14
+ const POWERSHELL_SCRIPT = path.join(ROOT, 'scripts', 'clipboard-vision.ps1');
15
+ const DOC = path.join(ROOT, 'SKILL.md');
16
+ const SECRET = 'argv-secret-must-not-leak';
17
+
18
+ function canonicalDestination(base) {
19
+ const url = new URL(base);
20
+ const scheme = url.protocol.slice(0, -1).toLowerCase();
21
+ const hostname = url.hostname.toLowerCase();
22
+ const port = url.port || (scheme === 'https' ? '443' : '80');
23
+ const basePath = url.pathname.replace(/\/+$/, '') || '';
24
+ const host = hostname.includes(':') ? `[${hostname}]` : hostname;
25
+ return `${scheme}://${host}:${port}${basePath}/chat/completions`;
26
+ }
27
+
28
+ function token(base, model = 'fixture-model') {
29
+ return crypto.createHash('sha256').update(`${canonicalDestination(base)}\n${model}`).digest('hex');
30
+ }
31
+
32
+ function fixture() {
33
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'clipboard-security-'));
34
+ const bin = path.join(root, 'bin');
35
+ const temp = path.join(root, 'temp');
36
+ fs.mkdirSync(bin);
37
+ fs.mkdirSync(temp);
38
+ const image = path.join(root, 'image.png');
39
+ fs.writeFileSync(image, Buffer.from('89504e470d0a1a0a', 'hex'));
40
+ const argv = path.join(root, 'curl-argv');
41
+ const authMode = path.join(root, 'auth-mode');
42
+ const clipboard = path.join(root, 'clipboard-count');
43
+ const network = path.join(root, 'network-count');
44
+ fs.writeFileSync(path.join(bin, 'pngpaste'), `#!/usr/bin/env bash
45
+ printf x >> "${clipboard}"
46
+ cp "${image}" "$1"
47
+ `, { mode: 0o755 });
48
+ fs.writeFileSync(path.join(bin, 'curl'), `#!/usr/bin/env bash
49
+ printf x >> "${network}"
50
+ printf '%s\n' "$@" > "${argv}"
51
+ while [ "$#" -gt 0 ]; do
52
+ if [ "$1" = '--header' ] && [[ "$2" = @* ]]; then stat -c '%a' "\${2#@}" > "${authMode}"; shift 2; continue; fi
53
+ if [ "$1" = '--output' ]; then out="$2"; shift 2; continue; fi
54
+ if [ "$1" = '--write-out' ]; then shift 2; continue; fi
55
+ shift
56
+ done
57
+ printf '%s' '{"choices":[{"message":{"content":"secure result"}}]}' > "$out"
58
+ printf 200
59
+ `, { mode: 0o755 });
60
+ return { root, bin, temp, argv, authMode, clipboard, network };
61
+ }
62
+
63
+ function run(state, base, args = [], env = {}) {
64
+ return spawnSync('bash', [SCRIPT, ...args], {
65
+ encoding: 'utf8',
66
+ env: {
67
+ ...process.env,
68
+ PATH: `${state.bin}:${process.env.PATH}`,
69
+ TMPDIR: state.temp,
70
+ CLIPBOARD_VISION_API_BASE: base,
71
+ CLIPBOARD_VISION_MODEL: 'fixture-model',
72
+ CLIPBOARD_VISION_API_KEY: SECRET,
73
+ ...env,
74
+ },
75
+ });
76
+ }
77
+
78
+ function count(file) {
79
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').length : 0;
80
+ }
81
+
82
+ describe('clipboard-vision security boundaries', () => {
83
+ let state;
84
+ beforeEach(() => { state = fixture(); });
85
+ afterEach(() => { fs.rmSync(state.root, { recursive: true, force: true }); });
86
+
87
+ it('keeps API credentials out of curl argv and cleans restrictive auth files', () => {
88
+ const result = run(state, 'http://127.0.0.1:11434/v1');
89
+
90
+ expect(result.status, `${result.stdout}${result.stderr}`).toBe(0);
91
+ expect(fs.readFileSync(state.argv, 'utf8')).not.toContain(SECRET);
92
+ expect(fs.readFileSync(state.authMode, 'utf8').trim()).toBe('600');
93
+ expect(fs.readdirSync(state.temp)).toEqual([]);
94
+ });
95
+
96
+ it.each([
97
+ ['scheme', 'http://vision.example.test/v1'],
98
+ ['port', 'https://vision.example.test:8443/v1'],
99
+ ['path', 'https://vision.example.test/other'],
100
+ ])('rejects a token after %s changes before clipboard or network', (_field, changedBase) => {
101
+ const stale = token('https://vision.example.test/v1');
102
+ const result = run(state, changedBase, ['--allow-remote', '--confirm-remote', stale]);
103
+
104
+ expect(result.status).not.toBe(0);
105
+ expect(count(state.clipboard)).toBe(0);
106
+ expect(count(state.network)).toBe(0);
107
+ });
108
+
109
+ it('rejects query and fragment components before clipboard or network', () => {
110
+ for (const base of ['https://vision.example.test/v1?tenant=a', 'https://vision.example.test/v1#fragment']) {
111
+ const result = run(state, base, ['--allow-remote', '--confirm-remote', token('https://vision.example.test/v1')]);
112
+ expect(result.status).not.toBe(0);
113
+ }
114
+ expect(count(state.clipboard)).toBe(0);
115
+ expect(count(state.network)).toBe(0);
116
+ });
117
+
118
+ it('treats localhost as remote and literal IPv4 and IPv6 loopback as local', () => {
119
+ const localhost = run(state, 'http://localhost:11434/v1');
120
+ const ipv4 = run(state, 'http://127.9.8.7:11434/v1');
121
+ const ipv6 = run(state, 'http://[::1]:11434/v1');
122
+
123
+ expect(localhost.status).not.toBe(0);
124
+ expect(ipv4.status, ipv4.stderr).toBe(0);
125
+ expect(ipv6.status, ipv6.stderr).toBe(0);
126
+ });
127
+
128
+ it('reuses a stable token only for the same canonical endpoint and exact model', () => {
129
+ const base = 'https://VISION.example.test/v1/';
130
+ const confirmation = token(base);
131
+ const first = run(state, base, ['--allow-remote', '--confirm-remote', confirmation]);
132
+ const second = run(state, base, ['--allow-remote', '--confirm-remote', confirmation]);
133
+ const changedModel = run(state, base, ['--allow-remote', '--confirm-remote', confirmation], { CLIPBOARD_VISION_MODEL: 'other-model' });
134
+
135
+ expect(first.status).toBe(0);
136
+ expect(second.status).toBe(0);
137
+ expect(changedModel.status).not.toBe(0);
138
+ });
139
+
140
+ it('rejects --output and never writes a user-selected path', () => {
141
+ const outputPath = path.join(state.root, 'output.txt');
142
+ const result = run(state, 'http://127.0.0.1:11434/v1', ['--output', outputPath]);
143
+
144
+ expect(result.status).not.toBe(0);
145
+ expect(fs.existsSync(outputPath)).toBe(false);
146
+ });
147
+
148
+ it('documents stable consent artifact and no-follow output behavior', () => {
149
+ const content = fs.readFileSync(DOC, 'utf8');
150
+ expect(content).toContain('consent artifact');
151
+ expect(content).toContain('canonical endpoint');
152
+ expect(content).toContain('stdout-only');
153
+ expect(content).toContain('not persisted');
154
+ });
155
+
156
+ it('documents the ephemeral mode-600 auth-header file and cleanup accurately', () => {
157
+ const content = fs.readFileSync(DOC, 'utf8');
158
+ expect(content).toContain('mode 600');
159
+ expect(content).toContain('ephemeral auth-header file');
160
+ expect(content).toContain('removed during cleanup');
161
+ expect(content).not.toContain('Never put secrets in arguments, files, logs, or errors');
162
+ });
163
+
164
+ it('requires PowerShell behavior cases through the executable script seams', () => {
165
+ const script = fs.readFileSync(POWERSHELL_SCRIPT, 'utf8');
166
+ const harness = fs.readFileSync(path.join(ROOT, '__tests__', 'clipboard-vision.Tests.ps1'), 'utf8');
167
+ expect(harness).toMatch(/Describe\s+['"]clipboard-vision PowerShell behavior['"]\s+\{/);
168
+ expect(harness.match(/\bIt\s+['"]/g)?.length).toBeGreaterThanOrEqual(8);
169
+ expect(script).not.toMatch(/\$Output|Set-Content.*Result/);
170
+ expect(script).not.toMatch(/\[string\]\$ConfirmRemote,\s*\)/);
171
+ expect(harness).toContain('No output parameter');
172
+ for (const marker of [
173
+ 'CV_TEST_MALFORMED_JSON',
174
+ 'CV_TEST_NON_STRING',
175
+ 'CV_TEST_HTTP_FAILURE',
176
+ 'CLIPBOARD_VISION_MAX_BYTES',
177
+ 'No output parameter',
178
+ 'http://localhost:',
179
+ 'http://[::1]:',
180
+ 'https://vision.example.test:8443/',
181
+ ]) {
182
+ expect(harness).toContain(marker);
183
+ }
184
+ });
185
+ });
@@ -0,0 +1,138 @@
1
+ # @test REQ-379 Windows privacy-safe clipboard vision
2
+ # @intent Verify bound remote consent and redirect blocking on Windows CI
3
+ # @covers AC-379-02, AC-379-03, AC-379-06
4
+
5
+ Describe 'clipboard-vision PowerShell behavior' {
6
+ BeforeAll {
7
+ $ScriptPath = Join-Path $PSScriptRoot '..\scripts\clipboard-vision.ps1'
8
+ $Fixture = Join-Path ([IO.Path]::GetTempPath()) ("clipboard-vision-pester-" + [Guid]::NewGuid())
9
+ $ImagePath = Join-Path $Fixture 'clipboard.png'
10
+ $CapturePath = Join-Path $Fixture 'request.json'
11
+ New-Item -ItemType Directory -Path $Fixture | Out-Null
12
+ [IO.File]::WriteAllBytes($ImagePath, [byte[]](137, 80, 78, 71, 13, 10, 26, 10))
13
+ $env:CLIPBOARD_VISION_TEST_IMAGE = $ImagePath
14
+ $env:CLIPBOARD_VISION_TEST_HTTP_CAPTURE = $CapturePath
15
+ $env:CLIPBOARD_VISION_MODEL = 'windows-fixture-model'
16
+
17
+ $ConsentBytes = [Text.Encoding]::UTF8.GetBytes("https://vision.example.test:443/v1/chat/completions`nwindows-fixture-model")
18
+ $Hasher = [Security.Cryptography.SHA256]::Create()
19
+ try {
20
+ $Token = ([BitConverter]::ToString($Hasher.ComputeHash($ConsentBytes))).Replace('-', '').ToLowerInvariant()
21
+ } finally {
22
+ $Hasher.Dispose()
23
+ }
24
+ }
25
+
26
+ BeforeEach {
27
+ Remove-Item -LiteralPath $CapturePath -ErrorAction SilentlyContinue
28
+ Remove-Item Env:CLIPBOARD_VISION_MAX_BYTES -ErrorAction SilentlyContinue
29
+ Remove-Item Env:CV_TEST_MALFORMED_JSON -ErrorAction SilentlyContinue
30
+ Remove-Item Env:CV_TEST_NON_STRING -ErrorAction SilentlyContinue
31
+ Remove-Item Env:CV_TEST_HTTP_FAILURE -ErrorAction SilentlyContinue
32
+ $env:CLIPBOARD_VISION_API_BASE = 'https://VISION.Example.Test/v1'
33
+ }
34
+
35
+ AfterAll {
36
+ Remove-Item Env:CLIPBOARD_VISION_TEST_IMAGE -ErrorAction SilentlyContinue
37
+ Remove-Item Env:CLIPBOARD_VISION_TEST_HTTP_CAPTURE -ErrorAction SilentlyContinue
38
+ Remove-Item Env:CLIPBOARD_VISION_API_BASE -ErrorAction SilentlyContinue
39
+ Remove-Item Env:CLIPBOARD_VISION_MODEL -ErrorAction SilentlyContinue
40
+ Remove-Item Env:CLIPBOARD_VISION_MAX_BYTES -ErrorAction SilentlyContinue
41
+ Remove-Item Env:CV_TEST_MALFORMED_JSON -ErrorAction SilentlyContinue
42
+ Remove-Item Env:CV_TEST_NON_STRING -ErrorAction SilentlyContinue
43
+ Remove-Item Env:CV_TEST_HTTP_FAILURE -ErrorAction SilentlyContinue
44
+ Remove-Item -LiteralPath $Fixture -Recurse -Force -ErrorAction SilentlyContinue
45
+ }
46
+
47
+ It 'rejects invalid endpoints before disclosure or HTTP capture' {
48
+ foreach ($InvalidBase in @(
49
+ 'relative/path',
50
+ 'ftp://vision.example.test/v1',
51
+ 'file:///tmp/vision',
52
+ 'https://user:secret@vision.example.test/v1'
53
+ )) {
54
+ $env:CLIPBOARD_VISION_API_BASE = $InvalidBase
55
+ $Disclosure = { & $ScriptPath -AllowRemote -ConfirmRemote 'unused' 2>&1 | Out-String }
56
+ $Disclosure | Should -Throw
57
+ Test-Path $CapturePath | Should -BeFalse
58
+ try { & $Disclosure | Out-Null } catch { $_.Exception.Message | Should -Not -Match 'Remote disclosure' }
59
+ }
60
+ }
61
+
62
+ It 'rejects stale consent after endpoint scheme port or path changes' {
63
+ foreach ($ChangedBase in @(
64
+ 'http://vision.example.test/v1',
65
+ 'https://vision.example.test:8443/v1',
66
+ 'https://vision.example.test/other'
67
+ )) {
68
+ $env:CLIPBOARD_VISION_API_BASE = $ChangedBase
69
+ { & $ScriptPath -AllowRemote -ConfirmRemote $Token 2>$null } | Should -Throw
70
+ Test-Path $CapturePath | Should -BeFalse
71
+ }
72
+ }
73
+
74
+ It 'rejects an unrelated stale confirmation token' {
75
+ { & $ScriptPath -AllowRemote -ConfirmRemote 'stale-token' 2>$null } | Should -Throw
76
+ Test-Path $CapturePath | Should -BeFalse
77
+ }
78
+
79
+ It 'returns the successful response and captures one request' {
80
+ $Result = & $ScriptPath -AllowRemote -ConfirmRemote $Token 2>$null
81
+
82
+ $Result | Should -BeExactly 'test description'
83
+ Test-Path $CapturePath | Should -BeTrue
84
+ (Get-Content -LiteralPath $CapturePath -Raw) | Should -Match 'windows-fixture-model'
85
+ }
86
+
87
+ It 'rejects malformed and non-string responses' {
88
+ $env:CV_TEST_MALFORMED_JSON = '1'
89
+ { & $ScriptPath -AllowRemote -ConfirmRemote $Token 2>$null } | Should -Throw
90
+ Remove-Item Env:CV_TEST_MALFORMED_JSON
91
+
92
+ $env:CV_TEST_NON_STRING = '1'
93
+ { & $ScriptPath -AllowRemote -ConfirmRemote $Token 2>$null } | Should -Throw '*redacted*'
94
+ }
95
+
96
+ It 'redacts HTTP failures without exposing the secret response body' {
97
+ $env:CV_TEST_HTTP_FAILURE = '1'
98
+ $Failure = { & $ScriptPath -AllowRemote -ConfirmRemote $Token 2>&1 }
99
+
100
+ $Failure | Should -Throw '*redacted*'
101
+ try { & $Failure | Out-Null } catch {
102
+ $_.Exception.Message | Should -Not -Match 'secret'
103
+ }
104
+ }
105
+
106
+ It 'rejects an oversized image' {
107
+ $env:CLIPBOARD_VISION_MAX_BYTES = '4'
108
+
109
+ { & $ScriptPath -AllowRemote -ConfirmRemote $Token 2>$null } | Should -Throw '*exceeds*'
110
+ }
111
+
112
+ It 'enforces No output parameter support without writing a file' {
113
+ $OutputPath = Join-Path $Fixture 'result.txt'
114
+
115
+ { & $ScriptPath -AllowRemote -ConfirmRemote $Token -Output $OutputPath 2>$null } | Should -Throw
116
+ Test-Path $OutputPath | Should -BeFalse
117
+ }
118
+
119
+ It 'treats localhost as remote and literal IPv4 and IPv6 loopback as local' {
120
+ $env:CLIPBOARD_VISION_API_BASE = 'http://localhost:11434/v1'
121
+ { & $ScriptPath 2>$null } | Should -Throw
122
+
123
+ foreach ($LoopbackBase in @('http://127.9.8.7:11434/v1', 'http://[::1]:11434/v1')) {
124
+ $env:CLIPBOARD_VISION_API_BASE = $LoopbackBase
125
+ & $ScriptPath 2>$null | Should -BeExactly 'test description'
126
+ }
127
+ }
128
+
129
+ It 'keeps redirect blocking and executable test seams in the production script' {
130
+ $Content = Get-Content -LiteralPath $ScriptPath -Raw
131
+
132
+ $Content | Should -Match 'MaximumRedirection\s+0'
133
+ $Content | Should -Match 'SHA256'
134
+ $Content | Should -Match 'CLIPBOARD_VISION_TEST_IMAGE'
135
+ $Content | Should -Match 'CLIPBOARD_VISION_TEST_HTTP_CAPTURE'
136
+ $Content | Should -Not -Match '\$Output|Set-Content.*Result'
137
+ }
138
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * @test REQ-379 privacy-safe clipboard vision
3
+ * @intent Verify one-shot clipboard image analysis enforces local-first privacy boundaries
4
+ * @covers AC-379-01, AC-379-02, AC-379-03, AC-379-04, AC-379-05, AC-379-06
5
+ */
6
+ const fs = require('fs');
7
+ const crypto = require('crypto');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+
12
+ const ROOT = path.resolve(__dirname, '..');
13
+ const BASH_SCRIPT = path.join(ROOT, 'scripts', 'clipboard-vision.sh');
14
+ const POWERSHELL_SCRIPT = path.join(ROOT, 'scripts', 'clipboard-vision.ps1');
15
+ const SECRET = 'cv-secret-must-never-leak';
16
+
17
+ function writeExecutable(file, content) {
18
+ fs.writeFileSync(file, content, { mode: 0o755 });
19
+ }
20
+
21
+ function createFixture() {
22
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'clipboard-vision-test-'));
23
+ const bin = path.join(root, 'bin');
24
+ const temp = path.join(root, 'temp');
25
+ fs.mkdirSync(bin);
26
+ fs.mkdirSync(temp);
27
+ const image = path.join(root, 'clipboard.png');
28
+ fs.writeFileSync(image, Buffer.from('89504e470d0a1a0a', 'hex'));
29
+ const clipboardCount = path.join(root, 'clipboard-count');
30
+ const curlCount = path.join(root, 'curl-count');
31
+ const curlArgs = path.join(root, 'curl-args');
32
+ const curlBody = path.join(root, 'curl-body');
33
+
34
+ writeExecutable(path.join(bin, 'pngpaste'), `#!/usr/bin/env bash
35
+ set -euo pipefail
36
+ printf x >> "${clipboardCount}"
37
+ cp "${image}" "$1"
38
+ `);
39
+ writeExecutable(path.join(bin, 'curl'), `#!/usr/bin/env bash
40
+ set -euo pipefail
41
+ printf x >> "${curlCount}"
42
+ printf '%s\n' "$@" > "${curlArgs}"
43
+ while [ "$#" -gt 0 ]; do
44
+ if [ "$1" = '--data-binary' ]; then cp "\${2#@}" "${curlBody}"; shift 2; continue; fi
45
+ if [ "$1" = '--output' ]; then output="$2"; shift 2; continue; fi
46
+ if [ "$1" = '--write-out' ]; then shift 2; continue; fi
47
+ shift
48
+ done
49
+ if [ -n "\${CV_FAKE_RESPONSE:-}" ]; then
50
+ printf '%s' "$CV_FAKE_RESPONSE" > "$output"
51
+ else
52
+ printf '%s' '{"choices":[{"message":{"content":"described safely"}}]}' > "$output"
53
+ fi
54
+ printf '%s' "\${CV_FAKE_STATUS:-200}"
55
+ `);
56
+
57
+ return { root, bin, temp, image, clipboardCount, curlCount, curlArgs, curlBody };
58
+ }
59
+
60
+ function run(fixture, args = [], env = {}) {
61
+ return spawnSync('bash', [BASH_SCRIPT, ...args], {
62
+ encoding: 'utf8',
63
+ env: {
64
+ ...process.env,
65
+ PATH: `${fixture.bin}:${process.env.PATH}`,
66
+ TMPDIR: fixture.temp,
67
+ CLIPBOARD_VISION_MODEL: 'fixture-model',
68
+ CLIPBOARD_VISION_API_KEY: SECRET,
69
+ ...env,
70
+ },
71
+ });
72
+ }
73
+
74
+ function combined(result) {
75
+ return `${result.stdout}${result.stderr}`;
76
+ }
77
+
78
+ function count(file) {
79
+ return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').length : 0;
80
+ }
81
+
82
+ function consentToken(hostname, model, base = `https://${hostname}/v1`) {
83
+ const url = new URL(base);
84
+ const scheme = url.protocol.slice(0, -1).toLowerCase();
85
+ const normalizedHost = url.hostname.toLowerCase();
86
+ const port = url.port || (scheme === 'https' ? '443' : '80');
87
+ const displayHost = normalizedHost.includes(':') ? `[${normalizedHost}]` : normalizedHost;
88
+ const endpoint = `${scheme}://${displayHost}:${port}${url.pathname.replace(/\/+$/, '')}/chat/completions`;
89
+ return crypto.createHash('sha256').update(`${endpoint}\n${model}`, 'utf8').digest('hex');
90
+ }
91
+
92
+ function assertNoTempFiles(fixture) {
93
+ expect(fs.readdirSync(fixture.temp)).toEqual([]);
94
+ }
95
+
96
+ describe('clipboard-vision Bash client', () => {
97
+ let fixture;
98
+
99
+ beforeEach(() => {
100
+ fixture = createFixture();
101
+ });
102
+
103
+ afterEach(() => {
104
+ fs.rmSync(fixture.root, { recursive: true, force: true });
105
+ });
106
+
107
+ it('fails without model configuration before reading clipboard or calling network', () => {
108
+ const result = run(fixture, [], { CLIPBOARD_VISION_MODEL: '' });
109
+
110
+ expect(result.status).not.toBe(0);
111
+ expect(count(fixture.clipboardCount)).toBe(0);
112
+ expect(count(fixture.curlCount)).toBe(0);
113
+ });
114
+
115
+ it('rejects a non-loopback endpoint in local mode before reading clipboard', () => {
116
+ const result = run(fixture, [], { CLIPBOARD_VISION_API_BASE: 'https://vision.example.test/v1' });
117
+
118
+ expect(result.status).not.toBe(0);
119
+ expect(count(fixture.clipboardCount)).toBe(0);
120
+ expect(count(fixture.curlCount)).toBe(0);
121
+ });
122
+
123
+ it('requires both remote opt-in and destination-bound confirmation', () => {
124
+ const endpoint = { CLIPBOARD_VISION_API_BASE: 'https://vision.example.test/v1' };
125
+ const optInOnly = run(fixture, ['--allow-remote'], endpoint);
126
+ const confirmationOnly = run(fixture, ['--confirm-remote', consentToken('vision.example.test', 'fixture-model')], endpoint);
127
+
128
+ expect(optInOnly.status).not.toBe(0);
129
+ expect(confirmationOnly.status).not.toBe(0);
130
+ expect(count(fixture.clipboardCount)).toBe(0);
131
+ expect(count(fixture.curlCount)).toBe(0);
132
+ });
133
+
134
+ it('prints disclosure and expected token before rejecting missing confirmation', () => {
135
+ const result = run(fixture, ['--allow-remote'], {
136
+ CLIPBOARD_VISION_API_BASE: 'https://vision.example.test/v1',
137
+ });
138
+
139
+ expect(result.status).not.toBe(0);
140
+ expect(result.stderr).toContain('vision.example.test');
141
+ expect(result.stderr).toContain('fixture-model');
142
+ expect(result.stderr.toLowerCase()).toContain('image');
143
+ expect(result.stderr).toContain(consentToken('vision.example.test', 'fixture-model'));
144
+ expect(count(fixture.clipboardCount)).toBe(0);
145
+ expect(count(fixture.curlCount)).toBe(0);
146
+ });
147
+
148
+ it('accepts the confirmation token bound to normalized hostname and exact model', () => {
149
+ const token = consentToken('vision.example.test', 'fixture-model');
150
+ const result = run(fixture, ['--allow-remote', '--confirm-remote', token], {
151
+ CLIPBOARD_VISION_API_BASE: 'https://VISION.Example.Test/v1',
152
+ });
153
+
154
+ expect(result.status, combined(result)).toBe(0);
155
+ expect(count(fixture.clipboardCount)).toBe(1);
156
+ expect(count(fixture.curlCount)).toBe(1);
157
+ });
158
+
159
+ it('rejects a token for a changed destination before clipboard or network', () => {
160
+ const staleToken = consentToken('old.example.test', 'fixture-model');
161
+ const result = run(fixture, ['--allow-remote', '--confirm-remote', staleToken], {
162
+ CLIPBOARD_VISION_API_BASE: 'https://vision.example.test/v1',
163
+ });
164
+
165
+ expect(result.status).not.toBe(0);
166
+ expect(count(fixture.clipboardCount)).toBe(0);
167
+ expect(count(fixture.curlCount)).toBe(0);
168
+ });
169
+
170
+ it('rejects a token for a changed model before clipboard or network', () => {
171
+ const staleToken = consentToken('vision.example.test', 'old-model');
172
+ const result = run(fixture, ['--allow-remote', '--confirm-remote', staleToken], {
173
+ CLIPBOARD_VISION_API_BASE: 'https://vision.example.test/v1',
174
+ });
175
+
176
+ expect(result.status).not.toBe(0);
177
+ expect(count(fixture.clipboardCount)).toBe(0);
178
+ expect(count(fixture.curlCount)).toBe(0);
179
+ });
180
+
181
+ it('never exposes the API key in output or request JSON', () => {
182
+ const result = run(fixture);
183
+
184
+ expect(result.status, combined(result)).toBe(0);
185
+ expect(combined(result)).not.toContain(SECRET);
186
+ expect(fs.readFileSync(fixture.curlBody, 'utf8')).not.toContain(SECRET);
187
+ });
188
+
189
+ it('rejects oversized clipboard images before network access', () => {
190
+ fs.writeFileSync(fixture.image, Buffer.alloc(33));
191
+ const result = run(fixture, [], { CLIPBOARD_VISION_MAX_BYTES: '32' });
192
+
193
+ expect(result.status).not.toBe(0);
194
+ expect(count(fixture.curlCount)).toBe(0);
195
+ assertNoTempFiles(fixture);
196
+ });
197
+
198
+ it('removes temporary files after success', () => {
199
+ const result = run(fixture);
200
+
201
+ expect(result.status, combined(result)).toBe(0);
202
+ expect(result.stdout.trim()).toBe('described safely');
203
+ assertNoTempFiles(fixture);
204
+ });
205
+
206
+ it('redacts API failure bodies and removes temporary files after failure', () => {
207
+ const response = `upstream body contains ${SECRET}`;
208
+ const result = run(fixture, [], { CV_FAKE_STATUS: '500', CV_FAKE_RESPONSE: response });
209
+
210
+ expect(result.status).not.toBe(0);
211
+ expect(combined(result)).not.toContain(response);
212
+ expect(combined(result)).not.toContain(SECRET);
213
+ assertNoTempFiles(fixture);
214
+ });
215
+
216
+ it('performs exactly one clipboard read and one request per explicit invocation', () => {
217
+ const result = run(fixture);
218
+
219
+ expect(result.status, combined(result)).toBe(0);
220
+ expect(count(fixture.clipboardCount)).toBe(1);
221
+ expect(count(fixture.curlCount)).toBe(1);
222
+ });
223
+ });
224
+
225
+ describe('clipboard-vision PowerShell contract', () => {
226
+ it('keeps credentials in the environment and enforces bound consent, bounds, and cleanup', () => {
227
+ const content = fs.readFileSync(POWERSHELL_SCRIPT, 'utf8');
228
+
229
+ expect(content).toMatch(/\$env:CLIPBOARD_VISION_API_KEY/);
230
+ expect(content).toMatch(/AllowRemote/);
231
+ expect(content).toMatch(/ConfirmRemote/);
232
+ expect(content).toMatch(/SHA256/i);
233
+ expect(content).toMatch(/CLIPBOARD_VISION_MAX_BYTES/);
234
+ expect(content).toMatch(/finally\s*\{/i);
235
+ expect(content).toMatch(/Remove-Item/);
236
+ expect(content).not.toMatch(/https:\/\/(?!127\.0\.0\.1|localhost|\[::1\])/);
237
+ });
238
+
239
+ it('disables HTTP redirects so loopback cannot forward the clipboard body remotely', () => {
240
+ const content = fs.readFileSync(POWERSHELL_SCRIPT, 'utf8');
241
+
242
+ expect(content).toMatch(/MaximumRedirection\s+0/);
243
+ });
244
+
245
+ it('validates an absolute HTTP(S) URI without credentials before disclosure or seams', () => {
246
+ const content = fs.readFileSync(POWERSHELL_SCRIPT, 'utf8');
247
+ const validationPosition = content.indexOf('IsAbsoluteUri');
248
+ const disclosurePosition = content.indexOf('Remote disclosure');
249
+ const clipboardPosition = content.indexOf('CLIPBOARD_VISION_TEST_IMAGE');
250
+
251
+ expect(validationPosition).toBeGreaterThan(0);
252
+ expect(content).toMatch(/Scheme[^\n]+https?/i);
253
+ expect(content).toMatch(/UserInfo/);
254
+ expect(validationPosition).toBeLessThan(disclosurePosition);
255
+ expect(validationPosition).toBeLessThan(clipboardPosition);
256
+ });
257
+
258
+ it('ships a discoverable Windows-CI executable behavior harness', () => {
259
+ expect(fs.existsSync(path.join(ROOT, '__tests__', 'clipboard-vision.Tests.ps1'))).toBe(true);
260
+ });
261
+
262
+ it('wires the discoverable PowerShell harness into windows-latest CI', () => {
263
+ let repoRoot = ROOT;
264
+ while (!fs.existsSync(path.join(repoRoot, '.github', 'workflows', 'cross-platform-ci.yml'))) {
265
+ repoRoot = path.dirname(repoRoot);
266
+ }
267
+ const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'cross-platform-ci.yml'), 'utf8');
268
+
269
+ expect(workflow).toContain('windows-latest');
270
+ expect(workflow).toContain('clipboard-vision.Tests.ps1');
271
+ expect(workflow).toMatch(/shell:\s*pwsh/);
272
+ });
273
+ });
@@ -0,0 +1,86 @@
1
+ [CmdletBinding()]
2
+ param(
3
+ [switch]$AllowRemote,
4
+ [string]$ConfirmRemote
5
+ )
6
+
7
+ $ErrorActionPreference = 'Stop'
8
+ $ApiBase = if ($env:CLIPBOARD_VISION_API_BASE) { $env:CLIPBOARD_VISION_API_BASE } else { 'http://127.0.0.1:11434/v1' }
9
+ $Model = $env:CLIPBOARD_VISION_MODEL
10
+ $ApiKey = $env:CLIPBOARD_VISION_API_KEY
11
+ $MaxBytes = if ($env:CLIPBOARD_VISION_MAX_BYTES) { [long]$env:CLIPBOARD_VISION_MAX_BYTES } else { 10485760 }
12
+
13
+ if (-not $Model) { throw 'CLIPBOARD_VISION_MODEL is required' }
14
+ if ($MaxBytes -le 0) { throw 'CLIPBOARD_VISION_MAX_BYTES must be positive' }
15
+ $Endpoint = $null
16
+ if (-not [Uri]::TryCreate($ApiBase, [UriKind]::Absolute, [ref]$Endpoint) -or
17
+ -not $Endpoint.IsAbsoluteUri -or
18
+ $Endpoint.Scheme -notin @('http', 'https') -or
19
+ $Endpoint.UserInfo.Length -ne 0 -or
20
+ $Endpoint.Query.Length -ne 0 -or
21
+ $Endpoint.Fragment.Length -ne 0) {
22
+ throw 'CLIPBOARD_VISION_API_BASE must be an absolute HTTP(S) URI without credentials'
23
+ }
24
+ $Address = $null
25
+ $LiteralAddress = [Net.IPAddress]::TryParse($Endpoint.Host.Trim('[', ']'), [ref]$Address)
26
+ $Loopback = $LiteralAddress -and [Net.IPAddress]::IsLoopback($Address)
27
+ $NormalizedHost = $Endpoint.Host.Trim('[', ']').ToLowerInvariant()
28
+ $EffectivePort = if ($Endpoint.IsDefaultPort) { if ($Endpoint.Scheme -eq 'https') { 443 } else { 80 } } else { $Endpoint.Port }
29
+ $DisplayHost = if ($NormalizedHost.Contains(':')) { "[$NormalizedHost]" } else { $NormalizedHost }
30
+ $BasePath = $Endpoint.AbsolutePath.TrimEnd('/')
31
+ $EffectiveEndpoint = "$($Endpoint.Scheme.ToLowerInvariant())://$DisplayHost`:$EffectivePort$BasePath/chat/completions"
32
+ if (-not $Loopback) {
33
+ $ConsentBytes = [Text.Encoding]::UTF8.GetBytes("$EffectiveEndpoint`n$Model")
34
+ $Hasher = [Security.Cryptography.SHA256]::Create()
35
+ try { $ExpectedConfirmation = ([BitConverter]::ToString($Hasher.ComputeHash($ConsentBytes))).Replace('-', '').ToLowerInvariant() } finally { $Hasher.Dispose() }
36
+ [Console]::Error.WriteLine("Remote disclosure: clipboard image data will be sent to $EffectiveEndpoint using model $Model.")
37
+ [Console]::Error.WriteLine("Confirmation token for this canonical endpoint and model: $ExpectedConfirmation")
38
+ if (-not $AllowRemote -or $ConfirmRemote -cne $ExpectedConfirmation) {
39
+ throw 'Remote endpoint requires -AllowRemote and the disclosed -ConfirmRemote token'
40
+ }
41
+ }
42
+
43
+ $TempDirectory = Join-Path ([IO.Path]::GetTempPath()) ("clipboard-vision-" + [Guid]::NewGuid())
44
+ $ImagePath = Join-Path $TempDirectory 'clipboard.png'
45
+ try {
46
+ New-Item -ItemType Directory -Path $TempDirectory | Out-Null
47
+ if ($env:CLIPBOARD_VISION_TEST_IMAGE) {
48
+ Copy-Item -LiteralPath $env:CLIPBOARD_VISION_TEST_IMAGE -Destination $ImagePath
49
+ } else {
50
+ Add-Type -AssemblyName System.Windows.Forms
51
+ Add-Type -AssemblyName System.Drawing
52
+ $Image = [Windows.Forms.Clipboard]::GetImage()
53
+ if ($null -eq $Image) { throw 'Clipboard does not contain an image' }
54
+ try { $Image.Save($ImagePath, [Drawing.Imaging.ImageFormat]::Png) } finally { $Image.Dispose() }
55
+ }
56
+ if ((Get-Item $ImagePath).Length -gt $MaxBytes) { throw "Clipboard image exceeds $MaxBytes bytes" }
57
+
58
+ $Encoded = [Convert]::ToBase64String([IO.File]::ReadAllBytes($ImagePath))
59
+ $Body = @{
60
+ model = $Model
61
+ messages = @(@{ role = 'user'; content = @(
62
+ @{ type = 'text'; text = 'Describe this clipboard image.' },
63
+ @{ type = 'image_url'; image_url = @{ url = "data:image/png;base64,$Encoded" } }
64
+ ) })
65
+ } | ConvertTo-Json -Depth 8 -Compress
66
+ $Headers = @{}
67
+ if ($ApiKey) { $Headers.Authorization = "Bearer $ApiKey" }
68
+ try {
69
+ if ($env:CLIPBOARD_VISION_TEST_HTTP_CAPTURE) {
70
+ Set-Content -LiteralPath $env:CLIPBOARD_VISION_TEST_HTTP_CAPTURE -Value $Body -Encoding utf8
71
+ if ($env:CV_TEST_HTTP_FAILURE) { throw 'test HTTP failure body secret' }
72
+ if ($env:CV_TEST_MALFORMED_JSON) { $Response = $null }
73
+ elseif ($env:CV_TEST_NON_STRING) { $Response = @{ choices = @(@{ message = @{ content = @{ value = 1 } } }) } }
74
+ else { $Response = @{ choices = @(@{ message = @{ content = 'test description' } }) } }
75
+ } else {
76
+ $Response = Invoke-RestMethod -Method Post -Uri $EffectiveEndpoint -Headers $Headers -ContentType 'application/json' -Body $Body -MaximumRedirection 0
77
+ }
78
+ } catch {
79
+ throw 'Vision API request failed (response body redacted)'
80
+ }
81
+ $Result = $Response.choices[0].message.content
82
+ if ($Result -isnot [string]) { throw 'Invalid vision API response (body redacted)' }
83
+ $Result
84
+ } finally {
85
+ Remove-Item -LiteralPath $TempDirectory -Recurse -Force -ErrorAction SilentlyContinue
86
+ }