@indigoai-us/hq-cli 5.77.14 → 5.78.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.
Files changed (308) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +42 -2
  33. package/dist/commands/pkg-install.js +39 -2
  34. package/dist/commands/run.js +6 -2
  35. package/dist/commands/secrets.js +120 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2256
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs-update-api-key.test.ts +0 -105
  146. package/src/commands/packs.test.ts +0 -88
  147. package/src/commands/packs.ts +0 -622
  148. package/src/commands/people.test.ts +0 -807
  149. package/src/commands/people.ts +0 -422
  150. package/src/commands/pkg-install.dispatch.test.ts +0 -103
  151. package/src/commands/pkg-install.ts +0 -221
  152. package/src/commands/pkg-list.test.ts +0 -79
  153. package/src/commands/pkg-list.ts +0 -13
  154. package/src/commands/pkg-remove.ts +0 -71
  155. package/src/commands/pkg-update.ts +0 -189
  156. package/src/commands/publish.test.ts +0 -650
  157. package/src/commands/publish.ts +0 -580
  158. package/src/commands/reindex.test.ts +0 -259
  159. package/src/commands/reindex.ts +0 -274
  160. package/src/commands/rescue.test.ts +0 -39
  161. package/src/commands/rescue.ts +0 -210
  162. package/src/commands/run.env-local.test.ts +0 -88
  163. package/src/commands/run.test.ts +0 -45
  164. package/src/commands/run.ts +0 -190
  165. package/src/commands/safe-extract.test.ts +0 -459
  166. package/src/commands/safe-extract.ts +0 -444
  167. package/src/commands/secrets-scope.test.ts +0 -56
  168. package/src/commands/secrets-scope.ts +0 -32
  169. package/src/commands/secrets.parse-destination.test.ts +0 -38
  170. package/src/commands/secrets.test.ts +0 -2050
  171. package/src/commands/secrets.ts +0 -2249
  172. package/src/commands/signals.ts +0 -351
  173. package/src/commands/skill.test.ts +0 -770
  174. package/src/commands/skill.ts +0 -796
  175. package/src/commands/sources.ts +0 -362
  176. package/src/commands/sync-mode.test.ts +0 -366
  177. package/src/commands/sync-mode.ts +0 -387
  178. package/src/commands/sync-narrow.test.ts +0 -633
  179. package/src/commands/sync-narrow.ts +0 -555
  180. package/src/commands/sync.ts +0 -165
  181. package/src/commands/team-sync.ts +0 -628
  182. package/src/commands/update.ts +0 -71
  183. package/src/commands/whoami.ts +0 -83
  184. package/src/commands/workers.test.ts +0 -158
  185. package/src/commands/workers.ts +0 -301
  186. package/src/index.test.ts +0 -32
  187. package/src/index.ts +0 -20
  188. package/src/lib/db/control-plane.test.ts +0 -59
  189. package/src/lib/db/control-plane.ts +0 -113
  190. package/src/lib/db/local.test.ts +0 -81
  191. package/src/lib/db/local.ts +0 -148
  192. package/src/lib/db/migrate.test.ts +0 -133
  193. package/src/lib/db/migrate.ts +0 -137
  194. package/src/lib/db/paths.test.ts +0 -112
  195. package/src/lib/db/paths.ts +0 -128
  196. package/src/lib/db/remote-engine.test.ts +0 -44
  197. package/src/lib/db/remote-engine.ts +0 -148
  198. package/src/lib/db/remote-sql.test.ts +0 -32
  199. package/src/lib/db/remote-sql.ts +0 -62
  200. package/src/lib/db/sql.test.ts +0 -106
  201. package/src/lib/db/sql.ts +0 -192
  202. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  203. package/src/lib/local-tree-diff.test.ts +0 -301
  204. package/src/lib/local-tree-diff.ts +0 -362
  205. package/src/lib/narrow-hint-banner.test.ts +0 -235
  206. package/src/lib/narrow-hint-banner.ts +0 -212
  207. package/src/lib/plan-limit-nag.test.ts +0 -317
  208. package/src/lib/plan-limit-nag.ts +0 -264
  209. package/src/main.ts +0 -371
  210. package/src/node-preflight.test.ts +0 -60
  211. package/src/node-preflight.ts +0 -67
  212. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  213. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  214. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  215. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  216. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  217. package/src/outpost/session-heartbeat.test.ts +0 -459
  218. package/src/outpost/session-heartbeat.ts +0 -877
  219. package/src/packaging.test.ts +0 -109
  220. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  221. package/src/run/discover-schemas.test.ts +0 -153
  222. package/src/run/discover-schemas.ts +0 -79
  223. package/src/run/hq-plugin.test.ts +0 -371
  224. package/src/run/hq-plugin.ts +0 -277
  225. package/src/run/varlock-shape.test.ts +0 -57
  226. package/src/schemas/hq-package.schema.json +0 -155
  227. package/src/sentry-before-send.test.ts +0 -359
  228. package/src/sentry-before-send.ts +0 -221
  229. package/src/sentry-epipe.test.ts +0 -37
  230. package/src/sentry-release.test.ts +0 -54
  231. package/src/sentry.test.ts +0 -24
  232. package/src/sentry.ts +0 -54
  233. package/src/strategies/link.ts +0 -62
  234. package/src/strategies/merge.ts +0 -142
  235. package/src/types.ts +0 -146
  236. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  237. package/src/utils/auth-error.test.ts +0 -40
  238. package/src/utils/auth-error.ts +0 -42
  239. package/src/utils/billing-gate.test.ts +0 -95
  240. package/src/utils/billing-gate.ts +0 -228
  241. package/src/utils/breadcrumb-buffer.ts +0 -18
  242. package/src/utils/cli-telemetry.test.ts +0 -153
  243. package/src/utils/cli-telemetry.ts +0 -61
  244. package/src/utils/cognito-session.machine.test.ts +0 -109
  245. package/src/utils/cognito-session.refresh.test.ts +0 -84
  246. package/src/utils/cognito-session.test.ts +0 -328
  247. package/src/utils/cognito-session.ts +0 -448
  248. package/src/utils/company-selection-error.test.ts +0 -42
  249. package/src/utils/company-selection-error.ts +0 -45
  250. package/src/utils/contribution-table.ts +0 -83
  251. package/src/utils/environmental-error.test.ts +0 -45
  252. package/src/utils/environmental-error.ts +0 -39
  253. package/src/utils/epipe.test.ts +0 -28
  254. package/src/utils/epipe.ts +0 -29
  255. package/src/utils/expected-cli-error.test.ts +0 -28
  256. package/src/utils/expected-cli-error.ts +0 -39
  257. package/src/utils/feedback-diagnostics.test.ts +0 -183
  258. package/src/utils/feedback-diagnostics.ts +0 -123
  259. package/src/utils/feedback-screenshots.test.ts +0 -134
  260. package/src/utils/feedback-screenshots.ts +0 -124
  261. package/src/utils/feedback-versions.test.ts +0 -98
  262. package/src/utils/feedback-versions.ts +0 -68
  263. package/src/utils/git.ts +0 -74
  264. package/src/utils/integrity.ts +0 -59
  265. package/src/utils/intercepted-process-exit.test.ts +0 -37
  266. package/src/utils/intercepted-process-exit.ts +0 -36
  267. package/src/utils/login-provider.test.ts +0 -42
  268. package/src/utils/login-provider.ts +0 -30
  269. package/src/utils/manifest.test.ts +0 -167
  270. package/src/utils/manifest.ts +0 -131
  271. package/src/utils/pack-contributions.test.ts +0 -603
  272. package/src/utils/pack-contributions.ts +0 -450
  273. package/src/utils/people.ts +0 -215
  274. package/src/utils/registry-client.ts +0 -204
  275. package/src/utils/registry.ts +0 -90
  276. package/src/utils/resolve-vault-credential.test.ts +0 -69
  277. package/src/utils/resolve-vault-credential.ts +0 -60
  278. package/src/utils/sandbox-runner-client.test.ts +0 -279
  279. package/src/utils/sandbox-runner-client.ts +0 -260
  280. package/src/utils/secrets-cache.ts +0 -183
  281. package/src/utils/sentry-identity.ts +0 -45
  282. package/src/utils/settle-with-timeout.test.ts +0 -21
  283. package/src/utils/settle-with-timeout.ts +0 -22
  284. package/src/utils/user-yaml-error.test.ts +0 -24
  285. package/src/utils/user-yaml-error.ts +0 -30
  286. package/src/utils/vault-api.test.ts +0 -536
  287. package/src/utils/vault-api.ts +0 -382
  288. package/src/utils/version-check.test.ts +0 -176
  289. package/src/utils/version-check.ts +0 -155
  290. package/src/utils/version-gate.test.ts +0 -1024
  291. package/src/utils/version-gate.ts +0 -702
  292. package/test/commands/db-tenant-isolation.test.ts +0 -94
  293. package/test/commands/db.test.ts +0 -85
  294. package/test/commands/signals.test.ts +0 -207
  295. package/test/commands/sources.test.ts +0 -231
  296. package/test/e2e/smoke-install-mcp.sh +0 -113
  297. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  298. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  299. package/test/fixtures/signals/action_item/sample.md +0 -16
  300. package/test/fixtures/signals/summary/sample.md +0 -12
  301. package/test/fixtures/sources/meetings/sample.md +0 -25
  302. package/test/helpers/cli-runner.ts +0 -150
  303. package/test/helpers/s3-list-mock.ts +0 -79
  304. package/test/helpers/vault-service-mock.ts +0 -295
  305. package/test/sources-signals/smoke.test.ts +0 -228
  306. package/tsconfig.json +0 -20
  307. package/vitest.config.ts +0 -11
  308. package/vitest.e2e.config.ts +0 -7
@@ -1,444 +0,0 @@
1
- /**
2
- * safe-extract — hardened tarball extraction for the content-pack installer.
3
- *
4
- * THREAT MODEL (US-020). A pack tarball is UNTRUSTED input fetched from npm,
5
- * a git remote, or a local path the user merely pointed at. Naively shelling
6
- * out to `tar -xzf <tarball> -C <dir>` trusts the archive to behave; a hostile
7
- * archive can:
8
- *
9
- * 1. Path traversal / "zip slip": an entry named `../../etc/cron.d/evil` or
10
- * an absolute `/etc/passwd` escapes the intended target dir and clobbers
11
- * arbitrary files (RCE if it lands a hook / cron / shell-rc).
12
- * 2. Symlink/hardlink escape: an entry that is a symlink whose target points
13
- * OUTSIDE the pack dir (e.g. `link -> /etc`), optionally followed by a
14
- * second entry that writes "through" the link — the classic two-step
15
- * tar symlink attack.
16
- * 3. Decompression bomb: a tiny `.tgz` that inflates to terabytes (many
17
- * files, or one enormous sparse/zero file), exhausting disk or memory.
18
- *
19
- * DEFENSE (defense-in-depth, default-deny):
20
- * - PRE-FLIGHT: enumerate every entry's header (type, size, name, link
21
- * target) via `tar -tv` WITHOUT extracting. Reject the whole archive if
22
- * ANY entry fails a containment or cap check. Containment uses normalized
23
- * path-prefix logic on the *resolved* path, never naive string matching.
24
- * - CAPS: enforced against the header-reported uncompressed sizes during the
25
- * pre-flight scan, so we abort BEFORE writing a single byte of a bomb.
26
- * - STAGED + ATOMIC: extraction lands in a sibling staging dir on the SAME
27
- * filesystem as the final destination; only after a successful, fully
28
- * validated extract do we `fs.rename` it into place (atomic). Any failure
29
- * mid-extract rolls the staging dir back entirely (`rm -rf` in finally) —
30
- * no partial/half-wired pack is ever left behind.
31
- * - POST-EXTRACT: an independent realpath containment sweep over everything
32
- * actually written (we do NOT rely solely on the pre-flight scan or on the
33
- * tar binary's own protections).
34
- */
35
-
36
- import { execFileSync } from 'child_process';
37
- import * as fs from 'fs';
38
- import * as path from 'path';
39
-
40
- // ---------------------------------------------------------------------------
41
- // Caps (decompression-bomb guard). Named constants with documented defaults.
42
- // Overridable per-call so tests can trip the tripwire cheaply (and so US-024's
43
- // adversarial suite can reuse low caps without building literal multi-GB
44
- // fixtures).
45
- // ---------------------------------------------------------------------------
46
-
47
- export interface ExtractCaps {
48
- /** Max total uncompressed bytes across all entries. Default 256 MiB. */
49
- maxUncompressedBytes: number;
50
- /** Max number of entries in the archive. Default 20,000. */
51
- maxFileCount: number;
52
- /** Max uncompressed bytes of any single entry. Default 64 MiB. */
53
- maxSingleFileBytes: number;
54
- }
55
-
56
- export const DEFAULT_CAPS: ExtractCaps = {
57
- // A content pack is docs/skills/policies/small scripts. 256 MiB is already
58
- // generous; anything larger is almost certainly a bomb or a mistake.
59
- maxUncompressedBytes: 256 * 1024 * 1024,
60
- maxFileCount: 20_000,
61
- maxSingleFileBytes: 64 * 1024 * 1024,
62
- };
63
-
64
- export class UnsafeArchiveError extends Error {
65
- constructor(message: string) {
66
- super(message);
67
- this.name = 'UnsafeArchiveError';
68
- }
69
- }
70
-
71
- // ---------------------------------------------------------------------------
72
- // Entry-header model + tar listing parser
73
- // ---------------------------------------------------------------------------
74
-
75
- export type EntryType = 'file' | 'dir' | 'symlink' | 'hardlink' | 'other';
76
-
77
- export interface TarEntry {
78
- type: EntryType;
79
- /** Entry path as recorded in the archive (may contain `..`, be absolute…). */
80
- name: string;
81
- /** Uncompressed size in bytes from the header (0 for dirs/links). */
82
- size: number;
83
- /** For sym/hardlinks: the link target as recorded in the archive. */
84
- linkTarget?: string;
85
- }
86
-
87
- /**
88
- * Parse the verbose listing produced by `tar -tv`. We use the verbose form so
89
- * we get the type flag, size, and (for links) the `name -> target` /
90
- * `name link to target` suffix without extracting anything.
91
- *
92
- * SECURITY-CRITICAL CROSS-PLATFORM NOTE. bsdtar (macOS/BSD) and GNU tar
93
- * (Linux / prod Lambdas) format the verbose listing DIFFERENTLY, and an
94
- * implementation that only understands one of them silently parses ZERO
95
- * entries against the other — which means the pre-flight containment scan
96
- * sees nothing to reject and every malicious archive sails through. The two
97
- * shapes we must both handle:
98
- *
99
- * bsdtar: <mode> <links> <owner> <group> <size> <Mon> <DD> <HH:MM|YYYY> <name...>
100
- * -rw-r--r-- 0 user group 1234 Jan 1 00:00 path/file
101
- * lrwxr-xr-x 0 user group 0 Jan 1 00:00 link -> target
102
- *
103
- * GNU: <mode> <owner>/<group> <size> <YYYY-MM-DD> <HH:MM[:SS]> <name...>
104
- * -rw-r--r-- user/group 1234 2026-01-01 00:00 path/file
105
- * lrwxrwxrwx user/group 0 2026-01-01 00:00 link -> target
106
- * hrw-r--r-- user/group 0 2026-01-01 00:00 hard link to target
107
- *
108
- * GNU has NO link-count column, joins owner/group with `/`, and renders the
109
- * date ISO-style (`YYYY-MM-DD HH:MM`) instead of `Mon DD HH:MM`. The previous
110
- * parser anchored on a `[A-Za-z]{3}` month token, so it matched NOTHING under
111
- * GNU tar and returned [] — the cross-platform hole this fix closes.
112
- *
113
- * Robust strategy: do NOT anchor on the (format-divergent) date column. Anchor
114
- * on the leading mode string (always present, position 0, stable type char),
115
- * then take the SIZE as the last integer that appears before the date/time
116
- * column, accepting BOTH date formats. The leading mode char is the type flag:
117
- * '-' file, 'd' dir, 'l' symlink, 'h' hardlink (GNU prints 'h'); bsdtar
118
- * prints hardlinks as files with a "link to" suffix, which we also detect.
119
- */
120
- export function parseTarListing(verbose: string): TarEntry[] {
121
- const entries: TarEntry[] = [];
122
- for (const raw of verbose.split('\n')) {
123
- const line = raw.replace(/\r$/, '');
124
- if (line.trim() === '') continue;
125
- const modeChar = line[0];
126
- // Only accept lines that actually begin with a tar mode string (10 chars:
127
- // a type flag followed by 9 permission chars, possibly with a trailing
128
- // ACL/xattr '+'/'@'/'.'). This skips non-entry noise without depending on
129
- // the date column at all.
130
- if (!/^[-dlhpscbD][-rwxXsStT]{9}[.+@]?\s/.test(line)) continue;
131
-
132
- // The size is the last run of digits that sits immediately before the
133
- // date/time column. We accept BOTH date renderings:
134
- // bsdtar: `Mon DD HH:MM` / `Mon DD YYYY`
135
- // GNU: `YYYY-MM-DD HH:MM[:SS]`
136
- // Anchoring the size on "the integer right before a recognized date" works
137
- // identically whether or not a link-count column is present (bsdtar) and
138
- // whether owner/group is one slash-joined token (GNU) or two columns
139
- // (bsdtar), because we only ever look at the integer adjacent to the date.
140
- const dateRe =
141
- /\s(\d+)\s+(?:[A-Za-z]{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{2}|\d{4})|\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}(?::\d{2})?)\s+/;
142
- const m = dateRe.exec(line);
143
- if (!m) continue;
144
- const size = Number.parseInt(m[1], 10);
145
- const nameAndLink = line.slice(m.index + m[0].length).trim();
146
-
147
- let type: EntryType;
148
- let name = nameAndLink;
149
- let linkTarget: string | undefined;
150
-
151
- if (modeChar === 'd') {
152
- type = 'dir';
153
- name = name.replace(/\/+$/, '');
154
- } else if (modeChar === 'l') {
155
- type = 'symlink';
156
- const arrow = nameAndLink.indexOf(' -> ');
157
- if (arrow >= 0) {
158
- name = nameAndLink.slice(0, arrow);
159
- linkTarget = nameAndLink.slice(arrow + 4);
160
- }
161
- } else if (modeChar === 'h') {
162
- type = 'hardlink';
163
- // GNU: "name link to target"
164
- const m = / link to /.exec(nameAndLink);
165
- if (m) {
166
- name = nameAndLink.slice(0, m.index);
167
- linkTarget = nameAndLink.slice(m.index + m[0].length);
168
- }
169
- } else if (modeChar === '-') {
170
- // bsdtar renders hardlinks as a file entry with a trailing "link to".
171
- const m = / link to /.exec(nameAndLink);
172
- if (m) {
173
- type = 'hardlink';
174
- name = nameAndLink.slice(0, m.index);
175
- linkTarget = nameAndLink.slice(m.index + m[0].length);
176
- } else {
177
- type = 'file';
178
- }
179
- } else {
180
- type = 'other';
181
- }
182
-
183
- entries.push({
184
- type,
185
- name,
186
- size: Number.isFinite(size) ? size : 0,
187
- linkTarget,
188
- });
189
- }
190
- return entries;
191
- }
192
-
193
- // ---------------------------------------------------------------------------
194
- // Containment — normalized prefix on the RESOLVED path (not string matching)
195
- // ---------------------------------------------------------------------------
196
-
197
- /**
198
- * True iff `candidate` is `base` itself or strictly nested under it, judged on
199
- * normalized absolute paths with a trailing-separator guard so that a sibling
200
- * like `/a/baseEVIL` is NOT considered "under" `/a/base`.
201
- */
202
- export function isContained(base: string, candidate: string): boolean {
203
- const b = path.resolve(base);
204
- const c = path.resolve(candidate);
205
- if (c === b) return true;
206
- const withSep = b.endsWith(path.sep) ? b : b + path.sep;
207
- return c.startsWith(withSep);
208
- }
209
-
210
- /**
211
- * Resolve where an entry NAME would land under `targetDir` and assert it stays
212
- * inside. Rejects absolute names and any `..` that escapes. Returns the
213
- * resolved absolute path on success.
214
- */
215
- export function resolveContainedPath(targetDir: string, entryName: string): string {
216
- // path.resolve collapses `..` segments and makes absolute names absolute to
217
- // the FS root (so `/etc/passwd` resolves to itself, which then fails
218
- // containment) — exactly what we want for the check.
219
- const resolved = path.resolve(targetDir, entryName);
220
- if (!isContained(targetDir, resolved)) {
221
- throw new UnsafeArchiveError(
222
- `Path traversal blocked: entry "${entryName}" resolves to "${resolved}", ` +
223
- `outside extraction dir "${path.resolve(targetDir)}".`,
224
- );
225
- }
226
- return resolved;
227
- }
228
-
229
- /**
230
- * Validate a sym/hardlink's target stays inside `packDir`. The link is created
231
- * AT `entryAbs` (already known-contained), so a relative target is resolved
232
- * against the link's parent dir; an absolute target is checked as-is. Either
233
- * way the final location must be contained in the pack dir.
234
- */
235
- export function assertLinkTargetContained(
236
- packDir: string,
237
- entryAbs: string,
238
- linkTarget: string,
239
- ): void {
240
- const resolvedTarget = path.isAbsolute(linkTarget)
241
- ? path.resolve(linkTarget)
242
- : path.resolve(path.dirname(entryAbs), linkTarget);
243
- if (!isContained(packDir, resolvedTarget)) {
244
- throw new UnsafeArchiveError(
245
- `Link escape blocked: entry "${path.relative(packDir, entryAbs)}" points to ` +
246
- `"${linkTarget}" -> "${resolvedTarget}", outside pack dir "${path.resolve(packDir)}".`,
247
- );
248
- }
249
- }
250
-
251
- // ---------------------------------------------------------------------------
252
- // Pre-flight: list entries, enforce containment + caps WITHOUT extracting
253
- // ---------------------------------------------------------------------------
254
-
255
- /**
256
- * List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
257
- * argv form — never a shell string — so a hostile filename can't break out.
258
- */
259
- export function listTarball(tarballPath: string): TarEntry[] {
260
- const out = execFileSync('tar', ['-tvf', tarballPath], {
261
- encoding: 'utf-8',
262
- maxBuffer: 64 * 1024 * 1024,
263
- stdio: ['ignore', 'pipe', 'pipe'],
264
- });
265
- return parseTarListing(out);
266
- }
267
-
268
- /**
269
- * Pre-flight validation against the entry headers. Enforces (a) containment of
270
- * every entry name under `targetDir`, (b) containment of every link target
271
- * under `targetDir`, and (c) the three decompression-bomb caps. Throws
272
- * UnsafeArchiveError on the first violation — default-deny.
273
- */
274
- export function validateEntries(
275
- entries: TarEntry[],
276
- targetDir: string,
277
- caps: ExtractCaps = DEFAULT_CAPS,
278
- ): void {
279
- // Count real members (dirs included — they still occupy inodes; cheap to
280
- // count and keeps "many empty dirs" bombs in scope).
281
- if (entries.length > caps.maxFileCount) {
282
- throw new UnsafeArchiveError(
283
- `Decompression-bomb guard: archive has ${entries.length} entries, ` +
284
- `exceeding the limit of ${caps.maxFileCount}.`,
285
- );
286
- }
287
-
288
- let total = 0;
289
- for (const e of entries) {
290
- if (e.type === 'other') {
291
- throw new UnsafeArchiveError(
292
- `Unsupported entry type for "${e.name}" — only files, dirs, and ` +
293
- `contained links are allowed.`,
294
- );
295
- }
296
-
297
- // (a) name containment — rejects `..` traversal and absolute paths.
298
- const entryAbs = resolveContainedPath(targetDir, e.name);
299
-
300
- // (b) link-target containment.
301
- if ((e.type === 'symlink' || e.type === 'hardlink') && e.linkTarget) {
302
- assertLinkTargetContained(targetDir, entryAbs, e.linkTarget);
303
- }
304
-
305
- // (c) per-file + cumulative caps.
306
- if (e.size > caps.maxSingleFileBytes) {
307
- throw new UnsafeArchiveError(
308
- `Decompression-bomb guard: entry "${e.name}" is ${e.size} bytes, ` +
309
- `exceeding the per-file limit of ${caps.maxSingleFileBytes}.`,
310
- );
311
- }
312
- total += e.size;
313
- if (total > caps.maxUncompressedBytes) {
314
- throw new UnsafeArchiveError(
315
- `Decompression-bomb guard: cumulative uncompressed size exceeded ` +
316
- `${caps.maxUncompressedBytes} bytes (reached ${total} before finishing the scan).`,
317
- );
318
- }
319
- }
320
- }
321
-
322
- // ---------------------------------------------------------------------------
323
- // Post-extract: independent realpath containment sweep
324
- // ---------------------------------------------------------------------------
325
-
326
- /**
327
- * Walk everything actually written under `stagingDir` and assert that no entry
328
- * — and, for symlinks, no link target — escapes. This is the defense-in-depth
329
- * backstop: it does not trust the pre-flight scan OR the tar binary. Uses
330
- * realpath on the link's PARENT (which exists) plus lstat to inspect links
331
- * without following them off-tree.
332
- */
333
- export function assertExtractContained(stagingDir: string): void {
334
- const realRoot = fs.realpathSync(stagingDir);
335
- const walk = (dir: string): void => {
336
- for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) {
337
- const abs = path.join(dir, dirent.name);
338
- if (dirent.isSymbolicLink()) {
339
- const target = fs.readlinkSync(abs);
340
- const resolved = path.isAbsolute(target)
341
- ? path.resolve(target)
342
- : path.resolve(path.dirname(abs), target);
343
- if (!isContained(realRoot, resolved)) {
344
- throw new UnsafeArchiveError(
345
- `Post-extract link escape: "${path.relative(realRoot, abs)}" -> "${target}".`,
346
- );
347
- }
348
- continue; // do not descend through links
349
- }
350
- // Real path of the entry's parent must stay inside the root (catches a
351
- // dir that is itself a link off-tree, belt-and-suspenders).
352
- const realParent = fs.realpathSync(path.dirname(abs));
353
- if (!isContained(realRoot, path.join(realParent, dirent.name))) {
354
- throw new UnsafeArchiveError(
355
- `Post-extract path escape: "${abs}" resolves outside "${realRoot}".`,
356
- );
357
- }
358
- if (dirent.isDirectory()) walk(abs);
359
- }
360
- };
361
- walk(realRoot);
362
- }
363
-
364
- // ---------------------------------------------------------------------------
365
- // Public: staged + atomic safe extraction
366
- // ---------------------------------------------------------------------------
367
-
368
- export interface SafeExtractOptions {
369
- caps?: ExtractCaps;
370
- /**
371
- * Test seam: invoked AFTER `tar` extracts into the staging dir but BEFORE
372
- * the post-extract sweep / atomic rename. Throwing here simulates a failure
373
- * "mid-extract" and must leave NO staging or final dir behind.
374
- */
375
- afterExtractHook?: (stagingDir: string) => void;
376
- }
377
-
378
- /**
379
- * Safely extract `tarballPath` so its contents land atomically at `finalDir`.
380
- *
381
- * Sequence:
382
- * 1. Pre-flight list + validate (containment + caps) — abort before writing.
383
- * 2. Extract into a sibling `*.staging-<rand>` dir on the SAME filesystem.
384
- * 3. Post-extract realpath containment sweep (independent backstop).
385
- * 4. `afterExtractHook` (test failure-injection seam).
386
- * 5. Atomic `fs.rename(staging -> finalDir)`.
387
- * 6. finally: `rm -rf` the staging dir if it still exists (rollback).
388
- *
389
- * On ANY failure the staging dir is removed and `finalDir` is never created,
390
- * so a half-extracted pack can never be wired.
391
- */
392
- export function safeExtractTarball(
393
- tarballPath: string,
394
- finalDir: string,
395
- opts: SafeExtractOptions = {},
396
- ): void {
397
- const caps = opts.caps ?? DEFAULT_CAPS;
398
-
399
- if (fs.existsSync(finalDir)) {
400
- throw new Error(
401
- `safeExtractTarball: refusing to extract over existing dir "${finalDir}".`,
402
- );
403
- }
404
-
405
- // 1. Pre-flight against headers (no bytes written yet).
406
- const entries = listTarball(tarballPath);
407
- validateEntries(entries, finalDir, caps);
408
-
409
- // 2. Sibling staging dir on the same filesystem so the rename is atomic.
410
- const parent = path.dirname(path.resolve(finalDir));
411
- fs.mkdirSync(parent, { recursive: true });
412
- const stagingDir = fs.mkdtempSync(
413
- path.join(parent, `.${path.basename(finalDir)}.staging-`),
414
- );
415
-
416
- try {
417
- // `--no-same-owner` avoids chown surprises when run as root; argv form, no
418
- // shell. We deliberately re-extract here (not the pre-flight listing) and
419
- // re-validate after, rather than trusting tar to honor any single flag.
420
- execFileSync(
421
- 'tar',
422
- ['-xzf', tarballPath, '-C', stagingDir, '--no-same-owner'],
423
- { stdio: 'inherit' },
424
- );
425
-
426
- // 3. Independent backstop sweep over what actually landed.
427
- assertExtractContained(stagingDir);
428
-
429
- // 4. Failure-injection seam (tests).
430
- opts.afterExtractHook?.(stagingDir);
431
-
432
- // 5. Atomic commit. `fs.renameSync` is atomic within a filesystem; the
433
- // staged contents are fully present and validated before this point, so
434
- // the live location only ever sees a complete pack — never a partial.
435
- fs.renameSync(stagingDir, finalDir);
436
- } finally {
437
- // Rollback: if anything before the rename threw, the staging dir still
438
- // exists and is removed here — no partial/half-wired pack survives. After a
439
- // successful rename the staging dir is gone, so this is a no-op.
440
- if (fs.existsSync(stagingDir)) {
441
- fs.rmSync(stagingDir, { recursive: true, force: true });
442
- }
443
- }
444
- }
@@ -1,56 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import {
4
- describeSecretsScope,
5
- formatSecretSaved,
6
- formatSecretsListEmpty,
7
- formatSecretsListHeader,
8
- } from "./secrets-scope.js";
9
-
10
- describe("describeSecretsScope", () => {
11
- it("describes the personal vault", () => {
12
- expect(
13
- describeSecretsScope({ personal: true, companyUid: "prs_alice" }),
14
- ).toBe("your personal vault");
15
- });
16
-
17
- it("describes a company by slug when present", () => {
18
- expect(
19
- describeSecretsScope({
20
- personal: false,
21
- companySlug: "acme",
22
- companyUid: "cmp_01ABC",
23
- }),
24
- ).toBe("company acme");
25
- });
26
-
27
- it("describes a company by uid when no slug is present", () => {
28
- expect(
29
- describeSecretsScope({ personal: false, companyUid: "cmp_01ABC" }),
30
- ).toBe("company cmp_01ABC");
31
- });
32
- });
33
-
34
- describe("formatSecretSaved", () => {
35
- it("formats the set echo", () => {
36
- expect(formatSecretSaved("MY_KEY", "your personal vault")).toBe(
37
- "Secret 'MY_KEY' saved to your personal vault.",
38
- );
39
- });
40
- });
41
-
42
- describe("formatSecretsListHeader", () => {
43
- it("formats the list header", () => {
44
- expect(formatSecretsListHeader("company acme")).toBe(
45
- "Secrets for company acme:",
46
- );
47
- });
48
- });
49
-
50
- describe("formatSecretsListEmpty", () => {
51
- it("formats the empty list message", () => {
52
- expect(formatSecretsListEmpty("your personal vault")).toBe(
53
- "No secrets found for your personal vault.",
54
- );
55
- });
56
- });
@@ -1,32 +0,0 @@
1
- /**
2
- * Pure helpers describing WHICH secrets scope a command acted on, so `set` and
3
- * `list` can echo it. Users were setting a secret in one scope (personal vs a
4
- * company, or company A vs B) and listing another, then seeing "no secrets" with
5
- * no indication the scopes differed (feedback_70e059da).
6
- */
7
- export interface SecretsScopeRef {
8
- /** True when --personal was used (caller's personal vault). */
9
- personal: boolean;
10
- /** The slug the user passed via --company, if any. */
11
- companySlug?: string;
12
- /** Resolved entity uid: prs_* for personal, cmp_* for a company. */
13
- companyUid: string;
14
- }
15
-
16
- /** Human label for a secrets scope: "your personal vault" or "company <slug-or-uid>". */
17
- export function describeSecretsScope(ref: SecretsScopeRef): string {
18
- if (ref.personal) return "your personal vault";
19
- return `company ${ref.companySlug ?? ref.companyUid}`;
20
- }
21
-
22
- export function formatSecretSaved(name: string, scope: string): string {
23
- return `Secret '${name}' saved to ${scope}.`;
24
- }
25
-
26
- export function formatSecretsListHeader(scope: string): string {
27
- return `Secrets for ${scope}:`;
28
- }
29
-
30
- export function formatSecretsListEmpty(scope: string): string {
31
- return `No secrets found for ${scope}.`;
32
- }
@@ -1,38 +0,0 @@
1
- /**
2
- * Pre-landing hardening (secrets-proxy-per-secret-destination): `--destination`
3
- * must reject an embedded `user:pass@host` userinfo segment EXPLICITLY rather
4
- * than letting `new URL()` silently drop it and normalize to the bare host.
5
- * Mirrors hq-pro's authoritative `validateDestinations` server-side check — the
6
- * server re-validates and remains authoritative, but the CLI must not present a
7
- * userinfo URL as an accepted destination.
8
- */
9
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
- import { parseDestinationUrl } from "./secrets.js";
11
-
12
- describe("parseDestinationUrl — userinfo hardening", () => {
13
- beforeEach(() => {
14
- // parseDestinationUrl logs the rejection reason to stderr; silence it so the
15
- // test output stays clean while still exercising the real code path.
16
- vi.spyOn(console, "error").mockImplementation(() => {});
17
- });
18
- afterEach(() => {
19
- vi.restoreAllMocks();
20
- });
21
-
22
- it.each([
23
- "https://user:pass@api.openai.com",
24
- "https://user@api.openai.com",
25
- "https://:pass@api.openai.com",
26
- ])("rejects a destination with embedded userinfo (%s)", (raw) => {
27
- expect(parseDestinationUrl(raw).ok).toBe(false);
28
- });
29
-
30
- it("still accepts a bare scheme+host HTTPS destination (guards against over-rejection)", () => {
31
- const result = parseDestinationUrl("https://api.openai.com");
32
- expect(result.ok).toBe(true);
33
- if (result.ok) {
34
- expect(result.url).toBe("https://api.openai.com");
35
- expect(result.hostname).toBe("api.openai.com");
36
- }
37
- });
38
- });