fullstackgtm 0.27.0 → 0.28.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.
@@ -1,8 +1,10 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { refreshHubspotToken } from "./connectors/hubspotAuth.js";
5
6
  import { refreshSalesforceToken } from "./connectors/salesforceAuth.js";
7
+ import { detectKeychainBackend } from "./keychain.js";
6
8
  /**
7
9
  * Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
8
10
  * $FSGTM_HOME/credentials.json when set. Environment tokens always win over
@@ -118,38 +120,103 @@ function enforceCredentialFileMode(path) {
118
120
  // Missing file or non-POSIX filesystem: nothing to enforce.
119
121
  }
120
122
  }
123
+ /**
124
+ * Persistence backend for the credential blob: the OS keychain when
125
+ * FSGTM_KEYCHAIN=1 and one is available, otherwise the 0600 file. The keychain
126
+ * account is derived from the credential file path so distinct homes/profiles
127
+ * never collide in the machine-wide store.
128
+ */
129
+ function activeKeychain() {
130
+ if (process.env.FSGTM_KEYCHAIN !== "1")
131
+ return null;
132
+ const backend = detectKeychainBackend();
133
+ if (!backend)
134
+ return null;
135
+ return { account: createHash("sha256").update(credentialsPath()).digest("hex").slice(0, 24), backend };
136
+ }
137
+ /**
138
+ * When keychain is enabled on an install that previously wrote a plaintext
139
+ * credentials.json, that file would otherwise sit on disk forever (readFile only
140
+ * looks at the keychain). Import it into the keychain and remove it — the whole
141
+ * point of keychain mode is that no plaintext credential remains at rest.
142
+ */
143
+ function migratePlaintextToKeychain(keychain) {
144
+ if (!existsSync(credentialsPath()))
145
+ return;
146
+ try {
147
+ const fileParsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
148
+ if (fileParsed && fileParsed.version === 1 && fileParsed.providers) {
149
+ const current = keychain.backend.get(keychain.account);
150
+ const existing = current ? (JSON.parse(current).providers ?? {}) : {};
151
+ // Keychain entries win over the file on conflict (the file is the older copy).
152
+ const merged = { version: 1, providers: { ...fileParsed.providers, ...existing } };
153
+ keychain.backend.set(keychain.account, `${JSON.stringify(merged, null, 2)}\n`);
154
+ }
155
+ unlinkSync(credentialsPath());
156
+ console.error("fullstackgtm: migrated credentials.json into the OS keychain and removed the plaintext file.");
157
+ }
158
+ catch {
159
+ // Best effort: a malformed/locked file is left in place rather than lost.
160
+ }
161
+ }
121
162
  function readFile() {
163
+ const keychain = activeKeychain();
164
+ if (keychain)
165
+ migratePlaintextToKeychain(keychain);
122
166
  try {
123
- enforceCredentialFileMode(credentialsPath());
124
- const parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
125
- if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
126
- return parsed;
167
+ const raw = keychain ? keychain.backend.get(keychain.account) : (enforceCredentialFileMode(credentialsPath()), readFileSync(credentialsPath(), "utf8"));
168
+ if (raw) {
169
+ const parsed = JSON.parse(raw);
170
+ if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
171
+ return parsed;
172
+ }
127
173
  }
128
174
  }
129
175
  catch {
130
- // Missing or unreadable file falls through to an empty store.
176
+ // Missing or unreadable store falls through to an empty one.
131
177
  }
132
178
  return { version: 1, providers: {} };
133
179
  }
180
+ function persist(file) {
181
+ const keychain = activeKeychain();
182
+ const blob = `${JSON.stringify(file, null, 2)}\n`;
183
+ if (keychain) {
184
+ keychain.backend.set(keychain.account, blob);
185
+ }
186
+ else {
187
+ ensureSecureHomeDir();
188
+ writeSecureFile(credentialsPath(), blob);
189
+ }
190
+ }
134
191
  export function getCredential(provider) {
135
192
  return readFile().providers[provider] ?? null;
136
193
  }
137
194
  export function storeCredential(provider, credential) {
138
195
  const file = readFile();
139
196
  file.providers[provider] = credential;
140
- ensureSecureHomeDir();
141
- writeSecureFile(credentialsPath(), `${JSON.stringify(file, null, 2)}\n`);
197
+ persist(file);
142
198
  }
143
199
  export function deleteCredential(provider) {
144
200
  const file = readFile();
145
201
  if (!file.providers[provider])
146
202
  return false;
147
203
  delete file.providers[provider];
148
- if (Object.keys(file.providers).length === 0 && existsSync(credentialsPath())) {
149
- unlinkSync(credentialsPath());
150
- return true;
204
+ const keychain = activeKeychain();
205
+ if (Object.keys(file.providers).length === 0) {
206
+ if (keychain) {
207
+ keychain.backend.delete(keychain.account);
208
+ // Defensive: remove any leftover plaintext file too (migration normally
209
+ // already did, but never leave a credential blob on disk after logout).
210
+ if (existsSync(credentialsPath()))
211
+ unlinkSync(credentialsPath());
212
+ return true;
213
+ }
214
+ if (existsSync(credentialsPath())) {
215
+ unlinkSync(credentialsPath());
216
+ return true;
217
+ }
151
218
  }
152
- writeSecureFile(credentialsPath(), `${JSON.stringify(file, null, 2)}\n`);
219
+ persist(file);
153
220
  return true;
154
221
  }
155
222
  const REFRESH_SKEW_MS = 2 * 60 * 1000;
@@ -222,6 +289,13 @@ async function brokerMint(provider, options) {
222
289
  const broker = getCredential("broker");
223
290
  if (!broker?.baseUrl)
224
291
  return null;
292
+ // The mint replays the long-lived bearer and receives a live CRM token —
293
+ // refuse to do so over cleartext even if a tampered store points us at http.
294
+ const brokerUrl = new URL(broker.baseUrl);
295
+ const localhost = brokerUrl.hostname === "localhost" || brokerUrl.hostname === "127.0.0.1" || brokerUrl.hostname === "::1" || brokerUrl.hostname === "[::1]";
296
+ if (brokerUrl.protocol !== "https:" && !(brokerUrl.protocol === "http:" && localhost)) {
297
+ throw new Error(`Refusing to mint a CRM token over ${brokerUrl.protocol}//${brokerUrl.host} — the broker must use https. Re-pair with an https deployment.`);
298
+ }
225
299
  const fetchImpl = options.fetchImpl ?? fetch;
226
300
  const response = await fetchImpl(`${broker.baseUrl.replace(/\/$/, "")}/api/cli/token`, {
227
301
  method: "POST",
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Optional OS-keychain backing for the credential store. Off by default;
3
+ * enabled with FSGTM_KEYCHAIN=1. When on, the credential blob is stored in the
4
+ * OS secret store instead of a 0600 file, so a cloned home, a restored backup,
5
+ * or another tool reading `~/.fullstackgtm/credentials.json` finds nothing.
6
+ *
7
+ * Backends shell out to the OS tool — no native dependency, so the package
8
+ * stays zero-dep:
9
+ * - Linux: `secret-tool` (libsecret) — reads the secret from STDIN (no argv leak).
10
+ * - macOS: `security` — `add-generic-password` only accepts the secret via the
11
+ * `-w` argv flag, so it is briefly visible to same-user `ps` during the call.
12
+ * That transient, same-user exposure is strictly smaller than a persistent
13
+ * plaintext file (which the same processes can read at any time), but it is a
14
+ * real caveat, documented in SECURITY.md.
15
+ *
16
+ * Keychain entries are NOT scoped by $FSGTM_HOME (the OS store is machine-wide),
17
+ * so the account name is derived from the credential file path to keep distinct
18
+ * homes/profiles from colliding. This is also why keychain is opt-in: defaulting
19
+ * it on would make throwaway-home test/eval runs write to the machine keychain.
20
+ */
21
+ export type KeychainBackend = {
22
+ readonly name: string;
23
+ get(account: string): string | null;
24
+ set(account: string, secret: string): void;
25
+ delete(account: string): void;
26
+ };
27
+ /** Test seam: force a backend (or null to force "none"). undefined = re-detect. */
28
+ export declare function setKeychainBackendForTests(backend: KeychainBackend | null | undefined): void;
29
+ /** The active backend for this platform, or null if none is available. */
30
+ export declare function detectKeychainBackend(): KeychainBackend | null;
@@ -0,0 +1,85 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { platform } from "node:os";
3
+ const SERVICE = "fullstackgtm";
4
+ function hasBinary(bin) {
5
+ try {
6
+ execFileSync("/usr/bin/env", ["which", bin], { stdio: "ignore" });
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ const macosBackend = {
14
+ name: "macos-keychain",
15
+ get(account) {
16
+ try {
17
+ return execFileSync("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"], {
18
+ encoding: "utf8",
19
+ stdio: ["ignore", "pipe", "ignore"],
20
+ }).replace(/\n$/, "");
21
+ }
22
+ catch {
23
+ return null; // not found → non-zero exit
24
+ }
25
+ },
26
+ set(account, secret) {
27
+ // -U updates if present. NOTE: the secret is in argv for the duration of
28
+ // this call (see the module comment); `security` has no stdin path.
29
+ execFileSync("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", secret], {
30
+ stdio: "ignore",
31
+ });
32
+ },
33
+ delete(account) {
34
+ try {
35
+ execFileSync("security", ["delete-generic-password", "-s", SERVICE, "-a", account], { stdio: "ignore" });
36
+ }
37
+ catch {
38
+ // already absent
39
+ }
40
+ },
41
+ };
42
+ const secretToolBackend = {
43
+ name: "linux-secret-tool",
44
+ get(account) {
45
+ try {
46
+ return execFileSync("secret-tool", ["lookup", "service", SERVICE, "account", account], {
47
+ encoding: "utf8",
48
+ stdio: ["ignore", "pipe", "ignore"],
49
+ });
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ },
55
+ set(account, secret) {
56
+ // secret-tool reads the secret from STDIN — no argv exposure.
57
+ execFileSync("secret-tool", ["store", "--label", `${SERVICE} ${account}`, "service", SERVICE, "account", account], {
58
+ input: secret,
59
+ stdio: ["pipe", "ignore", "ignore"],
60
+ });
61
+ },
62
+ delete(account) {
63
+ try {
64
+ execFileSync("secret-tool", ["clear", "service", SERVICE, "account", account], { stdio: "ignore" });
65
+ }
66
+ catch {
67
+ // already absent
68
+ }
69
+ },
70
+ };
71
+ let override;
72
+ /** Test seam: force a backend (or null to force "none"). undefined = re-detect. */
73
+ export function setKeychainBackendForTests(backend) {
74
+ override = backend;
75
+ }
76
+ /** The active backend for this platform, or null if none is available. */
77
+ export function detectKeychainBackend() {
78
+ if (override !== undefined)
79
+ return override;
80
+ if (platform() === "darwin" && hasBinary("security"))
81
+ return macosBackend;
82
+ if (platform() === "linux" && hasBinary("secret-tool"))
83
+ return secretToolBackend;
84
+ return null;
85
+ }
package/dist/mcp.js CHANGED
@@ -23,8 +23,15 @@ async function importPeer(specifier) {
23
23
  }
24
24
  catch (error) {
25
25
  try {
26
+ // Last-resort fallback to the invoking project's node_modules (the npx
27
+ // landmine: peers there, fullstackgtm in the npx cache). This loads code
28
+ // from the current working directory, so make it VISIBLE — running the
29
+ // MCP server in an untrusted directory could otherwise silently load a
30
+ // malicious `zod`/SDK from its node_modules.
26
31
  const projectRequire = createRequire(join(process.cwd(), "package.json"));
27
- return (await import(__rewriteRelativeImportExtension(pathToFileURL(projectRequire.resolve(specifier)).href)));
32
+ const resolved = projectRequire.resolve(specifier);
33
+ console.error(`fullstackgtm-mcp: loading peer "${specifier}" from the current directory (${resolved}). Only run the MCP server in a directory you trust.`);
34
+ return (await import(__rewriteRelativeImportExtension(pathToFileURL(resolved).href)));
28
35
  }
29
36
  catch {
30
37
  throw error; // the original error carries the missing-peer signal mcp-bin reports on
package/dist/types.d.ts CHANGED
@@ -281,6 +281,12 @@ export type PatchPlan = {
281
281
  filter?: {
282
282
  objectType: "account" | "contact" | "deal";
283
283
  where: string[];
284
+ /**
285
+ * The date the filter's comparison `today` literal resolves to (ISO
286
+ * yyyy-mm-dd). Stored so apply-time re-verification resolves `today`
287
+ * identically to plan time; absent on plans built before comparison ops.
288
+ */
289
+ today?: string;
284
290
  };
285
291
  /**
286
292
  * Plan-level guards re-evaluated against a FRESH snapshot at apply time.
package/docs/api.md CHANGED
@@ -91,8 +91,11 @@ emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
91
91
 
92
92
  - `buildBulkUpdatePlan(snapshot, options: BulkUpdateOptions)` with
93
93
  `parseWhere` (filter expressions: `=`, `!=`, `~`, `!~`, `:empty`,
94
- `:notempty`, `|` any-of, relational pseudo-fields) and
95
- `isFilterableField`. Filters are re-verified per record at apply time;
94
+ `:notempty`, type-aware comparisons `<`, `>`, `<=`, `>=` — `today` resolves
95
+ to `options.today`/the policy date, date and numeric fields coerce by value
96
+ form, `|` any-of, relational pseudo-fields) and `isFilterableField`. Filters
97
+ are re-verified per record at apply time (the resolved `today` rides along on
98
+ `plan.filter.today` so re-verification agrees with plan time);
96
99
  `from:<sourceField>` values derive per record from the snapshot.
97
100
  - `buildDedupePlan(snapshot, options: DedupeOptions)` with `dedupeKey` —
98
101
  duplicate groups by normalized identity key, one `merge_records` per group,
package/llms.txt CHANGED
@@ -61,7 +61,13 @@ Storage is profile-scoped under `<home>/market/<category>`. MCP:
61
61
  snapshot into a dry-run plan; the FULL filter is re-verified per record at
62
62
  apply time (plus mid-apply rechecks); equality filters double as
63
63
  preconditions, `--require`/`--guard` add explicit ones, `--max-operations`
64
- caps blast radius. `--set f=from:<source>` derives per-record values (empty
64
+ caps blast radius. Filter operators: `=` `!=` `~` `!~` `:empty` `:notempty`,
65
+ plus type-aware comparisons `<` `>` `<=` `>=` (`today` resolves to the policy
66
+ date, e.g. `closeDate<today`; date fields compare as dates, numeric as
67
+ numbers, unset/non-parseable values do not match). For date/count hygiene
68
+ (past close dates, stale deals, missing accounts, duplicates) prefer the
69
+ rule-backed `fix --rule <id>` — it encodes the date/open-deal logic
70
+ deterministically; reach for bulk-update only when no rule covers the task. `--set f=from:<source>` derives per-record values (empty
65
71
  source = skip + count, never guess). `--archive` refuses records sharing an
66
72
  identity key — merge with `dedupe` instead. `dedupe <object> --key
67
73
  <domain|email|name>` = one merge_records op per duplicate group,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM <security@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -37,7 +37,7 @@
37
37
  "DATA-FLOWS.md"
38
38
  ],
39
39
  "scripts": {
40
- "build": "tsc -p tsconfig.build.json",
40
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
41
41
  "test": "node --experimental-strip-types --test tests/*.test.ts",
42
42
  "prepublishOnly": "npm run build"
43
43
  },