fullstackgtm 0.48.0 → 0.50.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 (98) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +8 -2
  4. package/INSTALL_FOR_AGENTS.md +13 -4
  5. package/README.md +53 -1
  6. package/SECURITY.md +27 -3
  7. package/dist/acquireCheckpoint.d.ts +33 -0
  8. package/dist/acquireCheckpoint.js +149 -0
  9. package/dist/acquireLinkedIn.d.ts +2 -0
  10. package/dist/acquireLinkedIn.js +1 -1
  11. package/dist/cli/audit.js +10 -7
  12. package/dist/cli/auth.js +8 -4
  13. package/dist/cli/enrich.js +283 -79
  14. package/dist/cli/fix.js +20 -7
  15. package/dist/cli/help.js +23 -15
  16. package/dist/cli/market.js +180 -2
  17. package/dist/cli/plans.js +154 -9
  18. package/dist/cli/shared.d.ts +3 -1
  19. package/dist/cli/shared.js +2 -2
  20. package/dist/cli/ui.d.ts +10 -5
  21. package/dist/cli/ui.js +18 -5
  22. package/dist/cli.js +1 -1
  23. package/dist/config.d.ts +13 -1
  24. package/dist/config.js +23 -2
  25. package/dist/connectors/linkedin.d.ts +2 -0
  26. package/dist/connectors/linkedin.js +5 -0
  27. package/dist/connectors/prospectSources.d.ts +23 -0
  28. package/dist/connectors/prospectSources.js +25 -14
  29. package/dist/connectors/salesforce.js +12 -5
  30. package/dist/connectors/salesforceAuth.d.ts +9 -0
  31. package/dist/connectors/salesforceAuth.js +47 -5
  32. package/dist/connectors/theirstack.d.ts +0 -19
  33. package/dist/connectors/theirstack.js +3 -10
  34. package/dist/credentials.js +36 -26
  35. package/dist/enrich.d.ts +44 -1
  36. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  37. package/dist/hostedAcquireCheckpoint.js +129 -0
  38. package/dist/hostedPatchPlan.d.ts +87 -0
  39. package/dist/hostedPatchPlan.js +270 -0
  40. package/dist/icp.js +13 -2
  41. package/dist/index.d.ts +7 -3
  42. package/dist/index.js +6 -2
  43. package/dist/market.d.ts +1 -1
  44. package/dist/market.js +7 -127
  45. package/dist/marketClassify.d.ts +10 -0
  46. package/dist/marketClassify.js +52 -1
  47. package/dist/marketSourcing.js +7 -45
  48. package/dist/mcp.js +67 -115
  49. package/dist/planStore.d.ts +42 -1
  50. package/dist/planStore.js +268 -49
  51. package/dist/progress.d.ts +2 -2
  52. package/dist/progress.js +2 -2
  53. package/dist/providerError.d.ts +21 -0
  54. package/dist/providerError.js +30 -0
  55. package/dist/publicHttp.d.ts +28 -0
  56. package/dist/publicHttp.js +143 -0
  57. package/dist/secureFile.d.ts +15 -0
  58. package/dist/secureFile.js +164 -0
  59. package/dist/types.d.ts +1 -1
  60. package/docs/api.md +53 -2
  61. package/docs/architecture.md +22 -2
  62. package/llms.txt +7 -0
  63. package/package.json +5 -3
  64. package/skills/fullstackgtm/SKILL.md +8 -2
  65. package/src/acquireCheckpoint.ts +186 -0
  66. package/src/acquireLinkedIn.ts +3 -1
  67. package/src/cli/audit.ts +10 -7
  68. package/src/cli/auth.ts +8 -4
  69. package/src/cli/enrich.ts +322 -78
  70. package/src/cli/fix.ts +28 -7
  71. package/src/cli/help.ts +24 -15
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +159 -9
  74. package/src/cli/shared.ts +2 -1
  75. package/src/cli/ui.ts +19 -9
  76. package/src/cli.ts +1 -1
  77. package/src/config.ts +39 -1
  78. package/src/connectors/linkedin.ts +6 -0
  79. package/src/connectors/prospectSources.ts +50 -15
  80. package/src/connectors/salesforce.ts +12 -5
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/theirstack.ts +3 -10
  83. package/src/credentials.ts +47 -28
  84. package/src/enrich.ts +47 -1
  85. package/src/hostedAcquireCheckpoint.ts +156 -0
  86. package/src/hostedPatchPlan.ts +286 -0
  87. package/src/icp.ts +14 -2
  88. package/src/index.ts +24 -0
  89. package/src/market.ts +7 -111
  90. package/src/marketClassify.ts +61 -1
  91. package/src/marketSourcing.ts +7 -43
  92. package/src/mcp.ts +93 -136
  93. package/src/planStore.ts +322 -57
  94. package/src/progress.ts +2 -2
  95. package/src/providerError.ts +37 -0
  96. package/src/publicHttp.ts +147 -0
  97. package/src/secureFile.ts +169 -0
  98. package/src/types.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,97 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.50.0] — 2026-07-10
11
+
12
+ ### Added
13
+
14
+ - CLI and hosted now act as local-first replicas of one immutable patch plan.
15
+ Either surface may execute when it has a compatible CRM connection; online
16
+ execution uses a shared claim, while offline CLI work remains available and
17
+ reconciles later through stable provider idempotency and exact receipts.
18
+ - `fullstackgtm plans sync` explicitly exchanges approval revisions, terminal
19
+ state, and per-operation execution receipts. Plan list, show, and apply also
20
+ perform best-effort ambient check-ins.
21
+ - Hosted can execute fully synchronized CLI-origin plans through its connected
22
+ HubSpot or Salesforce connector. The CLI imports hosted runs without replaying
23
+ provider writes, and hosted imports exact CLI outcomes rather than inferring
24
+ success for every approved row.
25
+
26
+ ### Security
27
+
28
+ - Shared execution claims are bound to the immutable plan, exact approved
29
+ subset, replica, and paired CLI token. Approval edits freeze during apply;
30
+ stranded claims require an explicit audited recovery, and preflight release
31
+ is permitted only before provider I/O by the owning replica.
32
+ - Hosted ingestion recomputes the immutable document digest and verifies an
33
+ exact one-to-one operation set. Terminal transitions require complete,
34
+ duplicate-free receipts for the approved subset and cannot widen authority.
35
+
36
+ ### Fixed
37
+
38
+ - Paired CLIs now mirror saved plans into the hosted Patch Plans queue and
39
+ print an exact review deep-link. Mirrors are org-scoped, immutable and
40
+ idempotent; hosted approvals are hash/operation-verified and locally
41
+ HMAC-signed before CLI execution. Security state and evidence/findings stay
42
+ local, while bounded provider receipts replicate between capable executors.
43
+ - `enrich acquire` now reports discovery, resolution, and plan-building progress
44
+ across the previously silent provider/dedupe middle. Paired hosted runs render
45
+ the same live stage, counters, and generic progress notes on their detail page.
46
+ - Interactive acquisition runs retain both the CRM snapshot and acquisition
47
+ checklists in terminal history, then print a compact plan summary, copyable
48
+ review/approve/apply commands, and the paired hosted Runs deep-link.
49
+ - Persistent progress completion freezes the already-painted final frame rather
50
+ than repainting it, preventing duplicate live/final checklists in scrollback.
51
+ - Acquisition now persists query-bound Pipe0 cursors and HeyReach/Explorium
52
+ offsets so scheduled runs traverse the full audience instead of repeatedly
53
+ exhausting the first 25 results. `--max` controls desired new leads and the
54
+ new `--scan-limit` bounds raw duplicate-heavy scanning. Run history records
55
+ the complete discovery funnel and provider-confirmed exhaustion state.
56
+ - Checkpoints now live in a dedicated profile-scoped store keyed independently
57
+ by provider/source/list/query. Paired CLIs synchronize opaque continuation to
58
+ an organization-scoped hosted record using broker authentication and
59
+ compare-and-swap revisions; conflicts retain the newer hosted checkpoint.
60
+ - The documented LinkedIn `--list <id>` flag is accepted by strict CLI parsing,
61
+ and NAICS-only software ICPs retain their industry constraint on Pipe0.
62
+
63
+ ## [0.49.0] — 2026-07-10
64
+
65
+ ### Security
66
+
67
+ - MCP apply now accepts only store-backed, HMAC-approved plan ids; external
68
+ plan paths and caller-supplied approvals/value overrides are rejected.
69
+ - Apply uses an atomic single-owner claim and durable attempt journal.
70
+ Uncertain attempts remain fail-closed until an operator acknowledges provider
71
+ state; recovery clears every approval and never replays writes automatically.
72
+ - Salesforce credential-bearing endpoints are restricted to canonical HTTPS
73
+ Salesforce origins and redirects are handled without forwarding credentials.
74
+ - Executable rule packages require explicit CLI trust and are disabled entirely
75
+ in MCP. Implicit repository configuration cannot activate plugins.
76
+ - Sensitive local files use atomic no-follow writes and verified no-follow
77
+ reads; symlinked credential, plan, and managed-home paths are refused.
78
+ - Market capture/sourcing uses DNS-pinned public-only HTTP with per-hop redirect
79
+ validation, bounded responses, and cross-origin credential stripping.
80
+ - Provider HTTP failures expose typed status metadata without raw response
81
+ bodies, reflected secrets, or PII.
82
+
83
+ ### Changed
84
+
85
+ - **Breaking pre-1.0 plan-store contract:** `PlanStore` implementations now
86
+ participate in apply claims, attempt recovery, and claim-bound run recording;
87
+ `ApprovalStatus` includes `applying`. Custom stores must implement the new
88
+ lifecycle before upgrading.
89
+ - Public mirror generation is commit-derived, allowlisted, secret-scanned, and
90
+ fail-closed. npm publishing uses pinned actions/npm plus a protected release
91
+ environment and provenance verification.
92
+ - Package CI now validates Node 20/22, Linux/macOS/Windows compiled runtimes,
93
+ and actual installed tarballs with and without optional MCP peers.
94
+
95
+ ### Fixed
96
+
97
+ - `bulk-update --help` and `-h` now return focused help before parsing an object
98
+ type or touching configuration, credentials, disk, or network.
99
+ - Installation guidance no longer pins a brittle exact demo finding count.
100
+
10
101
  ## [0.48.0] — 2026-07-08
11
102
 
12
103
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -44,6 +44,21 @@ node --experimental-strip-types --test tests/fullstackgtm*.test.ts # from the
44
44
  node packages/fullstackgtm/src/bin.ts doctor # run the CLI locally
45
45
  ```
46
46
 
47
+ Before a release-facing change, verify the artifact rather than only its source
48
+ tree:
49
+
50
+ ```bash
51
+ cd packages/fullstackgtm
52
+ npm run build
53
+ node scripts/verify-packed-install.mjs # isolated install, optional peers absent
54
+ node scripts/verify-packed-install.mjs --with-peers # isolated install, MCP peers present
55
+ ```
56
+
57
+ The verifier creates the real npm tarball, enforces required/prohibited paths,
58
+ imports the public export, checks its declaration entrypoint, and runs both
59
+ installed binaries. CI repeats compiled-runtime checks on Node 20 and 22 across
60
+ Linux, macOS, and Windows; source-level tests remain on Node 22.6+.
61
+
47
62
  > **Tests live at the monorepo root `tests/`, not in the package.** Running
48
63
  > `npm test` *inside* `packages/fullstackgtm` deliberately fails with a pointer
49
64
  > (a `pretest` guard) rather than silently passing with zero tests. On the
@@ -78,13 +93,17 @@ the development monorepo). From the monorepo:
78
93
 
79
94
  1. Bump `packages/fullstackgtm/package.json` version + add a `CHANGELOG.md`
80
95
  entry; merge to `main`.
81
- 2. `scripts/sync-oss.sh --push` (builds a fresh `dist/`, mirrors the package +
82
- rewritten tests to `fullstackgtm/core`).
96
+ 2. Run `scripts/sync-oss.sh` to export an allowlisted mirror from the full
97
+ source commit, build `dist/`, scan for secrets, and inspect the complete
98
+ staged diff. Then rerun with `--push` after review. Clone failures stop the
99
+ release; only a genuine first publication may use the separately confirmed
100
+ `--initialize-public-repo` mode.
83
101
  3. Re-check the mirror's `package.json` version, then tag `vX.Y.Z` on the mirror
84
102
  and push the tag.
85
103
  4. GitHub Actions (`release.yml`) publishes to npm via **OIDC trusted
86
- publishing** — no tokens. It rebuilds from source and refuses to publish if
87
- the committed `dist/` differs from a fresh build (supply-chain gate).
104
+ publishing** — no tokens. The protected `npm-production` environment must
105
+ require an independent reviewer. The workflow rebuilds from source and
106
+ refuses to publish if committed `dist/` differs (supply-chain gate).
88
107
 
89
108
  **Access a new co-maintainer needs to cut a release:** write access to
90
109
  `github.com/fullstackgtm/core` (for `sync-oss.sh --push` and the tag push). No
package/DATA-FLOWS.md CHANGED
@@ -10,7 +10,8 @@ is no fullstackgtm-operated server in the data path for the open package.**
10
10
 
11
11
  - CRM snapshots, patch plans, approvals, apply-run records, market captures and
12
12
  observations, enrich run state, and the signing/credential stores all live
13
- under `$FSGTM_HOME` (default `~/.fullstackgtm`), `0600`/`0700`. Nothing is
13
+ under `$FSGTM_HOME` (default `~/.fullstackgtm`), `0600`/`0700`, using atomic
14
+ no-follow file I/O. Nothing is
14
15
  uploaded to Full Stack GTM.
15
16
  - No telemetry, analytics, or phone-home. The core package has zero runtime
16
17
  dependencies; the only network calls are the ones listed below, all to
@@ -23,12 +24,17 @@ is no fullstackgtm-operated server in the data path for the open package.**
23
24
  | `snapshot`, `audit`, `apply`, `resolve`, `bulk-update`, `dedupe`, `reassign`, `fix`, `enrich` (writeback) | **Your CRM** (HubSpot / Salesforce / Stripe API) | Reads: your CRM records. Writes: only approved patch operations. | Your CRM token (env / stored / broker) |
24
25
  | `call parse`, `call score`, `market classify`, `market refresh` | **Your LLM provider** (api.anthropic.com or api.openai.com) | The call transcript / captured competitor page text you point at, plus the extraction prompt | Your `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` (BYO) |
25
26
  | `enrich append --source apollo`, `enrich refresh` | **Apollo** (api.apollo.io) | The company domain / contact email being enriched | Your `APOLLO_API_KEY` (BYO) |
26
- | `market capture`, `market refresh` | **Public vendor websites** you list in `market.config.json` | An HTTP GET (no data sent beyond the request); SSRF-guarded to public hosts only | none |
27
+ | `market capture`, `market refresh` | **Public vendor websites** you list in `market.config.json` | A bounded HTTP GET; DNS answers are validated and pinned, mixed/private/reserved answers are refused, and every redirect is revalidated | none |
27
28
  | `login --via <url>` (optional) | **Your hosted deployment's broker** | A pairing handshake; the broker mints short-lived CRM tokens | broker pairing token |
29
+ | paired `enrich acquire` checkpoint sync (optional) | **Your hosted deployment** | Opaque query fingerprint plus provider cursor/list offset, exhaustion flag, and CAS revision. No prospect records, emails, names, or provider credentials. Scoped to the broker token's organization. | broker pairing token |
28
30
 
29
31
  Commands not listed (`plans`, `rules`, `doctor`, `schedule`, `audit-log`,
30
32
  `diff`, `merge`, report rendering) make **no network calls**.
31
33
 
34
+ Provider HTTP failures persist only typed provider/operation/status/retryability
35
+ metadata. Raw third-party response bodies—which may reflect credentials,
36
+ filters, or PII—are not written to run records or normal CLI/MCP errors.
37
+
32
38
  ## Avoiding third-party data egress
33
39
 
34
40
  - **LLM verbs are optional.** `call parse --deterministic` uses a free,
@@ -33,9 +33,11 @@ machine — that is normal and does not block the next step.
33
33
  fullstackgtm audit --demo --json
34
34
  ```
35
35
 
36
- Expect a JSON patch plan with `dryRun: true` and 110 findings/110 operations over a generated,
37
- deliberately messy CRM. Deterministic per seed: `--seed 7` is the default, so
38
- two runs produce identical finding and operation ids. Exit code 0.
36
+ Expect a JSON patch plan with `dryRun: true`, a non-empty `findings` array, and
37
+ matching proposed operations over a generated, deliberately messy CRM. The
38
+ exact count can grow as built-in rules are added; the stable contract is
39
+ determinism per release and seed. `--seed 7` is the default, so two runs on the
40
+ same version produce identical finding and operation ids. Exit code 0.
39
41
 
40
42
  This proves the whole pipeline (snapshot → audit → plan) without any account.
41
43
 
@@ -133,10 +135,16 @@ works from inside existing projects too.
133
135
  Tools exposed over stdio — read-only: `fullstackgtm_audit`,
134
136
  `fullstackgtm_capabilities`, `fullstackgtm_rules`, `fullstackgtm_suggest`,
135
137
  `fullstackgtm_call_parse`, `fullstackgtm_resolve`, `fullstackgtm_market_worksheet`. Gated:
136
- `fullstackgtm_apply` (requires explicit `approvedOperationIds`),
138
+ `fullstackgtm_apply` (stored `planId` only; approvals and values must already be
139
+ human-approved and HMAC-signed in the local plan store),
137
140
  `fullstackgtm_market_observe` (every quoted span is verified against the
138
141
  stored captures before anything is appended).
139
142
 
143
+ MCP cannot execute rule packages, apply external plan files, or supply its own
144
+ approvals/value overrides. For an interrupted apply, reconcile provider state,
145
+ then run `fullstackgtm plans recover <id> --acknowledge-uncertain-writes`; this
146
+ replays nothing and clears approvals so a fresh review is required.
147
+
140
148
  ## Troubleshooting
141
149
 
142
150
  | Symptom | Fix |
@@ -145,3 +153,4 @@ stored captures before anything is appended).
145
153
  | `No HubSpot credentials` (exit 1) | Set `HUBSPOT_ACCESS_TOKEN` or have a human run `fullstackgtm login hubspot --hosted` |
146
154
  | MCP server prints peer-dependency help | Install `@modelcontextprotocol/sdk` and `zod` (see step 6) |
147
155
  | Need machine state for orchestration | `fullstackgtm doctor --json` |
156
+ | Plan is `applying` after an interrupted request | Reconcile provider state, then run `fullstackgtm plans recover <id> --acknowledge-uncertain-writes`; review and approve again |
package/README.md CHANGED
@@ -192,6 +192,7 @@ echo "$PIPE0_API_KEY" | fullstackgtm login pipe0 # discovery + work-e
192
192
 
193
193
  fullstackgtm enrich acquire --provider hubspot # zero-config: ICP → discover → score → dedup → dry-run diff
194
194
  fullstackgtm enrich acquire --provider hubspot --max 25 --save # cap the batch, persist the needs_approval plan
195
+ fullstackgtm enrich acquire --provider hubspot --max 25 --scan-limit 500 --save # scan through duplicate-heavy pages for 25 fresh leads
195
196
  fullstackgtm plans approve <id> --operations all
196
197
  fullstackgtm apply --plan-id <id> --provider hubspot # the only step that creates contacts
197
198
  ```
@@ -206,6 +207,46 @@ The **ICP** (`icp.json`) is the single targeting artifact: it generates each pro
206
207
 
207
208
  Every run is **metered**: a per-profile budget caps both record count and provider spend (per-day + per-month, whichever binds first), charged only for creates that actually land. `enrich acquire` runs with just an `icp.json` and a `login` — sensible defaults for budget, provider, and create-mapping ship in the box.
208
209
 
210
+ Discovery continuation is profile-scoped and independently keyed by the exact
211
+ provider, source, list, and ICP query. Scheduled runs resume the saved Pipe0
212
+ cursor or HeyReach offset instead of rereading page one, even when several
213
+ lists alternate. When the CLI is paired, this non-PII checkpoint is mirrored
214
+ to the hosted organization with compare-and-swap so another authenticated
215
+ worker can resume without moving a newer cursor backwards. `--max` is the desired net-new plan size;
216
+ `--scan-limit` independently bounds raw candidates scanned after ICP filtering
217
+ and dedupe. `enrich status --runs` exposes the full funnel and whether the
218
+ audience is continuing or provider-confirmed exhausted.
219
+
220
+ When paired, every saved plan is also mirrored into the hosted Patch Plans
221
+ review queue and the CLI prints its exact deep-link. The mirror is immutable
222
+ and organization-scoped: replaying the same plan is idempotent, while the same
223
+ id with different content is rejected. Hosted reviewers can approve or reject;
224
+ the plan is executable from any synchronized surface with a compatible CRM
225
+ connection. A CLI-created plan can be applied in hosted, and a hosted approval
226
+ can be applied from the CLI—the plan's origin does not permanently own it.
227
+
228
+ This is local-first replication, not remote control. The local plan store works
229
+ offline, and plan commands perform a best-effort hosted check-in. Run
230
+ `fullstackgtm plans sync` to explicitly exchange every plan's latest approval,
231
+ execution claim, status, and receipts. If hosted applies while the CLI is
232
+ offline, the next check-in imports the exact per-operation results and marks
233
+ the local plan applied. If the CLI applies while hosted is unavailable, it
234
+ keeps the result locally and uploads the receipt on a later check-in.
235
+
236
+ Before either surface writes, it verifies the immutable plan hash, selected
237
+ operation ids, current approval revision, connector capability, and provider
238
+ state. Online replicas use a short-lived execution claim to coordinate; stable
239
+ operation ids, resolve-before-create, compare-and-set guards, and provider
240
+ readback protect offline retries. Receipts record each operation as `applied`,
241
+ `skipped`, or `failed`, plus provider record ids when available, so another
242
+ replica learns what happened rather than inferring it from plan status. A
243
+ conflicting plan body or approval revision is surfaced for review and is never
244
+ merged by expanding authority.
245
+
246
+ Local signing keys and HMAC digests never upload. Operation before/after values
247
+ do upload to the paired organization because they are required for informed
248
+ review and hosted execution.
249
+
209
250
  **Discovery sources** are zero-config presets behind `--source`: `explorium` and `pipe0` run ICP-driven people search, while `linkedin` (Phase 1 — discovery + dry-run) reads a pre-built **HeyReach** lead list:
210
251
 
211
252
  ```bash
@@ -373,6 +414,12 @@ fullstackgtm logout hubspot # or: salesforce | broker
373
414
 
374
415
  BYO direct credentials always win over a hosted broker pairing, so an operator can override the team default. First-party app secrets never ship in the npm package; hosted login stores a local broker token and mints provider tokens server-side. HubSpot does not support the device-authorization grant or secretless public clients, which is why the bring-your-own-app OAuth path requires client credentials; they are stored locally for silent refresh, the same model as `gcloud` and `aws` CLI profiles.
375
416
 
417
+ Salesforce credential-bearing URLs are restricted to canonical HTTPS
418
+ Salesforce-owned production, sandbox, and My Domain origins. Lookalike hosts,
419
+ userinfo, paths/query/fragment, nonstandard ports, unsafe token-response
420
+ instance URLs, and credential-forwarding redirects are refused both when the
421
+ credential is stored and every time it is used.
422
+
376
423
  ## Connect your CRM
377
424
 
378
425
  What each provider actually requires before `audit --provider <name>` works on your data.
@@ -539,10 +586,15 @@ Nine tools are exposed over stdio.
539
586
 
540
587
  **Read-only:** `fullstackgtm_audit` (sample, demo, file, or live provider sources with optional rule scoping), `fullstackgtm_capabilities` (server/tool capability manifest), `fullstackgtm_rules` (rule discovery), `fullstackgtm_suggest` (deterministic placeholder values with confidence + reasons), `fullstackgtm_call_parse` (transcripts → provenance-marked segments, insights, and evidence), `fullstackgtm_resolve` (the create gate: exists / ambiguous / safe_to_create), and `fullstackgtm_market_worksheet` (the classification packet for one vendor: claims, judging rules, captured page texts).
541
588
 
542
- **Gated:** `fullstackgtm_apply` (requires explicit `approvedOperationIds`; placeholders still need value overrides) and `fullstackgtm_market_observe` (verifies every quoted span against the stored captures before appending — nothing is stored unless the whole set passes).
589
+ **Gated:** `fullstackgtm_apply` (accepts only a stored plan id; approvals and values must already be human-approved and HMAC-signed in the plan store) and `fullstackgtm_market_observe` (verifies every quoted span against the stored captures before appending — nothing is stored unless the whole set passes).
543
590
 
544
591
  Tokens stored via `fullstackgtm login` are picked up automatically — the env var is only needed when no stored login exists.
545
592
 
593
+ Rule packages are executable JavaScript and are disabled by default. In the CLI,
594
+ review the modules and use `--config <path> --allow-plugins`; use `--no-plugins`
595
+ to apply only declarative config. MCP never executes rule packages: mutable
596
+ tool-call paths are not a durable code-trust boundary.
597
+
546
598
  ## Safety model
547
599
 
548
600
  1. Reads are safe by default; audits never mutate anything.
package/SECURITY.md CHANGED
@@ -21,7 +21,10 @@ newest version, not backported (the project is pre-1.0).
21
21
  **Credentials.** API tokens are never accepted as command-line arguments
22
22
  (they would leak into the process table and shell history); they come from an
23
23
  environment variable or stdin only, and are stored `0600` under a `0700` home
24
- (`$FSGTM_HOME`, default `~/.fullstackgtm`), re-tightened on read. This is the
24
+ (`$FSGTM_HOME`, default `~/.fullstackgtm`). Writes use exclusive temporary
25
+ files, fsync, and atomic rename; reads require regular files opened without
26
+ following symlinks. Managed-home, credential, and plan-store symlink paths are
27
+ refused before chmod/read/write. This is the
25
28
  same custody model as the `gcloud`/`aws` CLIs. The hosted broker
26
29
  (`login --via`) exists so a team can connect a CRM once, server-side, and hand
27
30
  laptops only a revocable pairing token instead of a long-lived super-admin key;
@@ -57,13 +60,34 @@ approved on one machine cannot be applied on another (the key does not travel).
57
60
  Documented boundary: this defends the plan file, not an attacker who already
58
61
  holds the signing key (same directory and permissions as the credential store).
59
62
 
63
+ **Single-owner apply and recovery.** Store-backed CLI and MCP writes atomically
64
+ claim an approved plan and journal the provider, caller surface, and attempt
65
+ state before provider I/O. A second process cannot apply the same approval.
66
+ Failures after provider I/O remain `applying`/uncertain because a timeout cannot
67
+ prove no remote write landed. Recovery never auto-replays: after reconciling CRM
68
+ state, `plans recover <id> --acknowledge-uncertain-writes` clears every approval
69
+ and signature and requires a fresh human review.
70
+
71
+ **Executable configuration.** `rulePackages` are arbitrary JavaScript. An
72
+ implicitly discovered repository config cannot activate them; CLI execution
73
+ requires explicit `--config <path> --allow-plugins` (`--no-plugins` disables
74
+ them). MCP never executes rule packages or accepts external plans/caller
75
+ approvals.
76
+
77
+ **Credential origins.** Salesforce access and refresh tokens are sent only to
78
+ canonical HTTPS Salesforce-owned origins. Userinfo, paths/query/fragment,
79
+ nonstandard ports, lookalike domains, unsafe token-response instance URLs, and
80
+ credential-preserving redirects are refused at input, storage, and use.
81
+
60
82
  **Scheduling never auto-approves.** Scheduled (cron) runs are restricted to a
61
83
  read/plan-side allowlist plus `apply --plan-id` whose approved status and
62
84
  signatures are re-checked at every firing. Arbitrary shell is not schedulable.
63
85
 
64
86
  **Untrusted input.** Competitor pages fetched by `market capture` are guarded
65
- against SSRF (scheme allowlist; private/loopback/link-local/metadata addresses
66
- refused; redirects re-validated). LLM-extracted call insights and market
87
+ against SSRF: every DNS answer must be globally routable, validated answers are
88
+ pinned into socket creation, mixed/private/reserved answers are refused, every
89
+ redirect is revalidated, sensitive headers are stripped cross-origin, and
90
+ time/body/redirect limits are enforced. LLM-extracted call insights and market
67
91
  classifications are mechanically verified verbatim against the source text
68
92
  before they can drive a writeback, so a prompt-injected transcript or page
69
93
  cannot fabricate a grounded-looking change. CSV/formula-injection in ingested
@@ -0,0 +1,33 @@
1
+ /** The complete identity of one independently traversable provider audience. */
2
+ export type AcquireCheckpointKey = {
3
+ provider: string;
4
+ source: string;
5
+ listId: string | null;
6
+ queryFingerprint: string;
7
+ };
8
+ export type AcquireContinuation = {
9
+ cursor?: string | null;
10
+ offset?: number | null;
11
+ exhausted: boolean;
12
+ };
13
+ /** Versioned wire format shared by local persistence and hosted sync. */
14
+ export type AcquireCheckpoint = {
15
+ version: 1;
16
+ id: string;
17
+ key: AcquireCheckpointKey;
18
+ continuation: AcquireContinuation;
19
+ updatedAt: string;
20
+ };
21
+ export declare function validateAcquireCheckpointKey(value: unknown): AcquireCheckpointKey;
22
+ /** Stable, non-secret filename/key suitable for both filesystem and hosted records. */
23
+ export declare function acquireCheckpointId(key: AcquireCheckpointKey): string;
24
+ export declare function validateAcquireCheckpoint(value: unknown): AcquireCheckpoint;
25
+ export declare function acquireCheckpointsDir(baseDir?: string): string;
26
+ export interface AcquireCheckpointStore {
27
+ get(key: AcquireCheckpointKey): Promise<AcquireCheckpoint | null>;
28
+ put(key: AcquireCheckpointKey, continuation: AcquireContinuation, updatedAt?: Date): Promise<AcquireCheckpoint>;
29
+ /** Import a validated local/hosted record without changing its timestamp. */
30
+ putRecord(record: AcquireCheckpoint): Promise<AcquireCheckpoint>;
31
+ list(): Promise<AcquireCheckpoint[]>;
32
+ }
33
+ export declare function createFileAcquireCheckpointStore(baseDir?: string): AcquireCheckpointStore;
@@ -0,0 +1,149 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { credentialsDir, ensureSecureHomeDir } from "./credentials.js";
5
+ import { assertNoSymlinkComponents, readSecureRegularFile, writeSecureFileAtomic, } from "./secureFile.js";
6
+ const COMPONENT_MAX = 512;
7
+ function requireComponent(value, name) {
8
+ if (typeof value !== "string" || value.length === 0 || value.length > COMPONENT_MAX || /[\u0000-\u001f]/.test(value)) {
9
+ throw new Error(`Invalid acquisition checkpoint ${name}`);
10
+ }
11
+ return value;
12
+ }
13
+ export function validateAcquireCheckpointKey(value) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
15
+ throw new Error("Invalid acquisition checkpoint key");
16
+ }
17
+ const candidate = value;
18
+ const listId = candidate.listId;
19
+ if (listId !== null && listId !== undefined)
20
+ requireComponent(listId, "listId");
21
+ return {
22
+ provider: requireComponent(candidate.provider, "provider"),
23
+ source: requireComponent(candidate.source, "source"),
24
+ listId: listId ?? null,
25
+ queryFingerprint: requireComponent(candidate.queryFingerprint, "queryFingerprint"),
26
+ };
27
+ }
28
+ /** Stable, non-secret filename/key suitable for both filesystem and hosted records. */
29
+ export function acquireCheckpointId(key) {
30
+ const valid = validateAcquireCheckpointKey(key);
31
+ const canonical = JSON.stringify([valid.provider, valid.source, valid.listId, valid.queryFingerprint]);
32
+ return `acqcp_${createHash("sha256").update(canonical).digest("hex")}`;
33
+ }
34
+ function validateContinuation(value) {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
36
+ throw new Error("Invalid acquisition checkpoint continuation");
37
+ }
38
+ const candidate = value;
39
+ if (typeof candidate.exhausted !== "boolean")
40
+ throw new Error("Invalid acquisition checkpoint exhausted flag");
41
+ if (candidate.cursor !== undefined && candidate.cursor !== null)
42
+ requireComponent(candidate.cursor, "cursor");
43
+ if (candidate.offset !== undefined && candidate.offset !== null &&
44
+ (!Number.isSafeInteger(candidate.offset) || candidate.offset < 0)) {
45
+ throw new Error("Invalid acquisition checkpoint offset");
46
+ }
47
+ return {
48
+ ...(candidate.cursor !== undefined ? { cursor: candidate.cursor } : {}),
49
+ ...(candidate.offset !== undefined ? { offset: candidate.offset } : {}),
50
+ exhausted: candidate.exhausted,
51
+ };
52
+ }
53
+ export function validateAcquireCheckpoint(value) {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
55
+ throw new Error("Invalid acquisition checkpoint");
56
+ }
57
+ const candidate = value;
58
+ if (candidate.version !== 1)
59
+ throw new Error(`Unsupported acquisition checkpoint version: ${String(candidate.version)}`);
60
+ const key = validateAcquireCheckpointKey(candidate.key);
61
+ const id = acquireCheckpointId(key);
62
+ if (candidate.id !== id)
63
+ throw new Error("Acquisition checkpoint id does not match its key");
64
+ if (typeof candidate.updatedAt !== "string" || !Number.isFinite(Date.parse(candidate.updatedAt))) {
65
+ throw new Error("Invalid acquisition checkpoint updatedAt");
66
+ }
67
+ return {
68
+ version: 1,
69
+ id,
70
+ key,
71
+ continuation: validateContinuation(candidate.continuation),
72
+ updatedAt: candidate.updatedAt,
73
+ };
74
+ }
75
+ export function acquireCheckpointsDir(baseDir) {
76
+ return join(baseDir ?? credentialsDir(), "acquire", "checkpoints");
77
+ }
78
+ export function createFileAcquireCheckpointStore(baseDir) {
79
+ const directory = acquireCheckpointsDir(baseDir);
80
+ function fileForId(id) {
81
+ return join(directory, `${id}.json`);
82
+ }
83
+ function readId(id) {
84
+ try {
85
+ return validateAcquireCheckpoint(JSON.parse(readSecureRegularFile(fileForId(id), { tightenMode: 0o600 })));
86
+ }
87
+ catch (error) {
88
+ if (error.code === "ENOENT")
89
+ return null;
90
+ throw error;
91
+ }
92
+ }
93
+ function ensureDirectory() {
94
+ if (baseDir === undefined)
95
+ ensureSecureHomeDir();
96
+ // Tighten both managed levels. mkdir's mode does not affect a directory
97
+ // restored or pre-created with broader permissions.
98
+ const levels = [join(baseDir ?? credentialsDir(), "acquire"), directory];
99
+ for (const level of levels) {
100
+ assertNoSymlinkComponents(level);
101
+ mkdirSync(level, { recursive: true, mode: 0o700 });
102
+ assertNoSymlinkComponents(level);
103
+ try {
104
+ chmodSync(level, 0o700);
105
+ }
106
+ catch { /* chmod is unavailable on some filesystems. */ }
107
+ }
108
+ }
109
+ function writeRecord(value) {
110
+ const record = validateAcquireCheckpoint(value);
111
+ ensureDirectory();
112
+ writeSecureFileAtomic(fileForId(record.id), `${JSON.stringify(record, null, 2)}\n`);
113
+ return record;
114
+ }
115
+ return {
116
+ async get(key) {
117
+ return readId(acquireCheckpointId(key));
118
+ },
119
+ async put(key, continuation, updatedAt = new Date()) {
120
+ const validKey = validateAcquireCheckpointKey(key);
121
+ return writeRecord({
122
+ version: 1,
123
+ id: acquireCheckpointId(validKey),
124
+ key: validKey,
125
+ continuation: validateContinuation(continuation),
126
+ updatedAt: updatedAt.toISOString(),
127
+ });
128
+ },
129
+ async putRecord(record) {
130
+ return writeRecord(record);
131
+ },
132
+ async list() {
133
+ let names;
134
+ try {
135
+ assertNoSymlinkComponents(directory);
136
+ names = readdirSync(directory).filter((name) => /^acqcp_[a-f0-9]{64}\.json$/.test(name));
137
+ }
138
+ catch (error) {
139
+ if (error.code === "ENOENT")
140
+ return [];
141
+ throw error;
142
+ }
143
+ return names
144
+ .map((name) => readId(name.slice(0, -5)))
145
+ .filter((record) => record !== null)
146
+ .sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
147
+ },
148
+ };
149
+ }
@@ -25,6 +25,8 @@ export type DiscoverLinkedInOptions = {
25
25
  sourceId?: string;
26
26
  /** Hard cap on prospects pulled. */
27
27
  max?: number;
28
+ /** Opaque provider continuation cursor. */
29
+ cursor?: string;
28
30
  };
29
31
  /**
30
32
  * Discovery branch for `acquireFromApi`: read prospects from a LinkedIn source
@@ -41,7 +41,7 @@ export function linkedInProspectToProspect(lp) {
41
41
  * provider is injected so this is fully testable against the fake (no key).
42
42
  */
43
43
  export async function discoverLinkedInProspects(provider, options = {}) {
44
- const raw = await provider.fetchProspects({ sourceId: options.sourceId, max: options.max });
44
+ const raw = await provider.fetchProspects({ sourceId: options.sourceId, max: options.max, cursor: options.cursor });
45
45
  return raw.map(linkedInProspectToProspect);
46
46
  }
47
47
  /**
package/dist/cli/audit.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { auditSnapshot, defaultPolicy } from "../audit.js";
5
- import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.js";
5
+ import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.js";
6
6
  import { getCredential, resolveHubspotConnection } from "../credentials.js";
7
7
  import { patchPlanToMarkdown } from "../format.js";
8
8
  import { appendHealthEntry, computeHealth, healthToMarkdown, readHealthTimeline, saveWorkspaceSnapshot, summarizeHealth, activeWorkspaceProfile } from "../health.js";
@@ -169,8 +169,9 @@ async function registerHubspotWithBroker() {
169
169
  }
170
170
  export async function audit(args) {
171
171
  const threshold = failOnThreshold(args);
172
- const loaded = loadConfig(option(args, "--config") ?? undefined);
173
- const rules = selectedRules(args, await resolveConfiguredRules(loaded));
172
+ const explicitConfig = option(args, "--config") ?? undefined;
173
+ const loaded = loadConfig(explicitConfig);
174
+ const rules = selectedRules(args, await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig)));
174
175
  const snapshot = await readSnapshot(args);
175
176
  const policy = mergePolicy(defaultPolicy(), loaded?.config);
176
177
  const today = option(args, "--today");
@@ -326,8 +327,9 @@ function shortDay(at) {
326
327
  * re-fetching (useful for a plan produced earlier or by another machine).
327
328
  */
328
329
  export async function reportCommand(args) {
329
- const loaded = loadConfig(option(args, "--config") ?? undefined);
330
- const configuredRules = await resolveConfiguredRules(loaded);
330
+ const explicitConfig = option(args, "--config") ?? undefined;
331
+ const loaded = loadConfig(explicitConfig);
332
+ const configuredRules = await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig));
331
333
  let plan;
332
334
  let snapshot;
333
335
  const planPath = option(args, "--plan");
@@ -375,8 +377,9 @@ export async function reportCommand(args) {
375
377
  }
376
378
  }
377
379
  export async function rulesCommand(args) {
378
- const loaded = loadConfig(option(args, "--config") ?? undefined);
379
- const rules = await resolveConfiguredRules(loaded);
380
+ const explicitConfig = option(args, "--config") ?? undefined;
381
+ const loaded = loadConfig(explicitConfig);
382
+ const rules = await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig));
380
383
  if (args.includes("--json")) {
381
384
  console.log(JSON.stringify(rules.map(({ id, title, description, category }) => ({
382
385
  id,
package/dist/cli/auth.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validateHubspotToken } from "../connectors/hubspotAuth.js";
5
- import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken } from "../connectors/salesforceAuth.js";
5
+ import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceOrigin, validateSalesforceToken } from "../connectors/salesforceAuth.js";
6
6
  import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, storeCredential } from "../credentials.js";
7
7
  import { activeWorkspaceProfile, readHealthTimeline, summarizeHealth } from "../health.js";
8
8
  import { resolveLlmCredential, validateLlmKey } from "../llm.js";
@@ -229,7 +229,10 @@ async function salesforceLogin(args) {
229
229
  await guidedProviderLogin("salesforce", args, "--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
230
230
  return;
231
231
  }
232
- const loginUrl = option(args, "--login-url") ?? undefined;
232
+ const loginUrlInput = option(args, "--login-url") ?? undefined;
233
+ const loginUrl = loginUrlInput
234
+ ? validateSalesforceOrigin(loginUrlInput, "Salesforce --login-url")
235
+ : undefined;
233
236
  const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
234
237
  console.error(`\nPairing code: ${authorization.userCode}\n\nConfirm this code at:\n\n ${authorization.verificationUri}\n`);
235
238
  void openInBrowser(authorization.verificationUri);
@@ -254,11 +257,12 @@ async function salesforceLogin(args) {
254
257
  console.log("Tokens refresh silently; no further browser interaction is needed.");
255
258
  return;
256
259
  }
257
- const instanceUrl = option(args, "--instance-url");
258
- if (!instanceUrl) {
260
+ const instanceUrlInput = option(args, "--instance-url");
261
+ if (!instanceUrlInput) {
259
262
  await guidedProviderLogin("salesforce", args, "Salesforce login needs hosted OAuth, --device --client-id <consumer key>, or --instance-url <https://yourorg.my.salesforce.com> with the access token piped on stdin.");
260
263
  return;
261
264
  }
265
+ const instanceUrl = validateSalesforceOrigin(instanceUrlInput, "Salesforce --instance-url");
262
266
  const token = await readSecret("Salesforce access token");
263
267
  if (!token)
264
268
  throw new Error("No access token provided.");