fullstackgtm 0.26.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.
@@ -0,0 +1,112 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { platform } from "node:os";
3
+
4
+ /**
5
+ * Optional OS-keychain backing for the credential store. Off by default;
6
+ * enabled with FSGTM_KEYCHAIN=1. When on, the credential blob is stored in the
7
+ * OS secret store instead of a 0600 file, so a cloned home, a restored backup,
8
+ * or another tool reading `~/.fullstackgtm/credentials.json` finds nothing.
9
+ *
10
+ * Backends shell out to the OS tool — no native dependency, so the package
11
+ * stays zero-dep:
12
+ * - Linux: `secret-tool` (libsecret) — reads the secret from STDIN (no argv leak).
13
+ * - macOS: `security` — `add-generic-password` only accepts the secret via the
14
+ * `-w` argv flag, so it is briefly visible to same-user `ps` during the call.
15
+ * That transient, same-user exposure is strictly smaller than a persistent
16
+ * plaintext file (which the same processes can read at any time), but it is a
17
+ * real caveat, documented in SECURITY.md.
18
+ *
19
+ * Keychain entries are NOT scoped by $FSGTM_HOME (the OS store is machine-wide),
20
+ * so the account name is derived from the credential file path to keep distinct
21
+ * homes/profiles from colliding. This is also why keychain is opt-in: defaulting
22
+ * it on would make throwaway-home test/eval runs write to the machine keychain.
23
+ */
24
+
25
+ export type KeychainBackend = {
26
+ readonly name: string;
27
+ get(account: string): string | null;
28
+ set(account: string, secret: string): void;
29
+ delete(account: string): void;
30
+ };
31
+
32
+ const SERVICE = "fullstackgtm";
33
+
34
+ function hasBinary(bin: string): boolean {
35
+ try {
36
+ execFileSync("/usr/bin/env", ["which", bin], { stdio: "ignore" });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ const macosBackend: KeychainBackend = {
44
+ name: "macos-keychain",
45
+ get(account) {
46
+ try {
47
+ return execFileSync("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"], {
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "ignore"],
50
+ }).replace(/\n$/, "");
51
+ } catch {
52
+ return null; // not found → non-zero exit
53
+ }
54
+ },
55
+ set(account, secret) {
56
+ // -U updates if present. NOTE: the secret is in argv for the duration of
57
+ // this call (see the module comment); `security` has no stdin path.
58
+ execFileSync("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", secret], {
59
+ stdio: "ignore",
60
+ });
61
+ },
62
+ delete(account) {
63
+ try {
64
+ execFileSync("security", ["delete-generic-password", "-s", SERVICE, "-a", account], { stdio: "ignore" });
65
+ } catch {
66
+ // already absent
67
+ }
68
+ },
69
+ };
70
+
71
+ const secretToolBackend: KeychainBackend = {
72
+ name: "linux-secret-tool",
73
+ get(account) {
74
+ try {
75
+ return execFileSync("secret-tool", ["lookup", "service", SERVICE, "account", account], {
76
+ encoding: "utf8",
77
+ stdio: ["ignore", "pipe", "ignore"],
78
+ });
79
+ } catch {
80
+ return null;
81
+ }
82
+ },
83
+ set(account, secret) {
84
+ // secret-tool reads the secret from STDIN — no argv exposure.
85
+ execFileSync("secret-tool", ["store", "--label", `${SERVICE} ${account}`, "service", SERVICE, "account", account], {
86
+ input: secret,
87
+ stdio: ["pipe", "ignore", "ignore"],
88
+ });
89
+ },
90
+ delete(account) {
91
+ try {
92
+ execFileSync("secret-tool", ["clear", "service", SERVICE, "account", account], { stdio: "ignore" });
93
+ } catch {
94
+ // already absent
95
+ }
96
+ },
97
+ };
98
+
99
+ let override: KeychainBackend | null | undefined;
100
+
101
+ /** Test seam: force a backend (or null to force "none"). undefined = re-detect. */
102
+ export function setKeychainBackendForTests(backend: KeychainBackend | null | undefined): void {
103
+ override = backend;
104
+ }
105
+
106
+ /** The active backend for this platform, or null if none is available. */
107
+ export function detectKeychainBackend(): KeychainBackend | null {
108
+ if (override !== undefined) return override;
109
+ if (platform() === "darwin" && hasBinary("security")) return macosBackend;
110
+ if (platform() === "linux" && hasBinary("secret-tool")) return secretToolBackend;
111
+ return null;
112
+ }
package/src/llm.ts CHANGED
@@ -109,8 +109,23 @@ export async function extractInsightsLlm(
109
109
  const result = (await forcedToolCall(prompt, "extract_call_insights", EXTRACT_SCHEMA, model, options)) as {
110
110
  insights?: LlmExtractedInsight[];
111
111
  };
112
+ const normalizedTranscript = normalizeSpan(text);
112
113
  const insights = (result.insights ?? [])
113
114
  .filter((insight) => INSIGHT_TYPES.includes(insight.type))
115
+ // Mechanical verbatim gate (mirrors market classify): the prompt asks for a
116
+ // verbatim quote, but a prompt-injected or hallucinated transcript could
117
+ // fabricate a grounded-looking insight that drives a governed writeback.
118
+ // (1) The evidence quote must be a non-trivial verbatim span of the transcript.
119
+ .filter((insight) => {
120
+ const quote = normalizeSpan(insight.evidence ?? "");
121
+ return quote.length >= 12 && normalizedTranscript.includes(quote);
122
+ })
123
+ // (2) For next_step — the only insight type whose `text` is WRITTEN to the CRM
124
+ // (set_field nextStep / create_task body) — the written action must itself be
125
+ // grounded in the verified quote, not just accompanied by an innocuous one.
126
+ // This closes the decoupling attack: a prompt-injected transcript that emits a
127
+ // malicious `text` while quoting an unrelated real span no longer survives.
128
+ .filter((insight) => insight.type !== "next_step" || actionGroundedInEvidence(insight.text, insight.evidence ?? ""))
114
129
  .map((insight) => ({
115
130
  ...insight,
116
131
  title: insight.type.replace(/_/g, " "),
@@ -121,6 +136,38 @@ export async function extractInsightsLlm(
121
136
  return { insights, model };
122
137
  }
123
138
 
139
+ /** Whitespace/punctuation-spacing-normalized match (same rule as market spans). */
140
+ function normalizeSpan(value: string): string {
141
+ return value
142
+ .replace(/\s+([.,;:!?])/g, "$1")
143
+ .replace(/\s+/g, " ")
144
+ .trim()
145
+ .toLowerCase();
146
+ }
147
+
148
+ /**
149
+ * Is the written next-step action grounded in its (already transcript-verified)
150
+ * evidence quote? A legitimate next step paraphrases the quote, so it reuses the
151
+ * quote's salient terms; a prompt-injected action ("wire $50,000 to account
152
+ * 1234") quoting an unrelated innocuous span does not. Two checks: every
153
+ * number/amount in the action must appear in the evidence (defeats the
154
+ * financial-exfil class cleanly), and a meaningful share of the action's
155
+ * distinctive (≥4-char) words must appear in the evidence.
156
+ */
157
+ function actionGroundedInEvidence(text: string, evidence: string): boolean {
158
+ const action = normalizeSpan(text);
159
+ const quote = normalizeSpan(evidence);
160
+ if (!action) return false;
161
+ const numbers = action.match(/\d[\d,.]*/g) ?? [];
162
+ for (const n of numbers) {
163
+ if (!quote.includes(n)) return false; // an ungrounded amount/account/id is a red flag
164
+ }
165
+ const distinctive = [...new Set(action.split(/[^a-z0-9$]+/).filter((token) => token.length >= 4))];
166
+ if (distinctive.length === 0) return true; // nothing distinctive to ground (a short generic step)
167
+ const grounded = distinctive.filter((token) => quote.includes(token)).length;
168
+ return grounded / distinctive.length >= 0.4;
169
+ }
170
+
124
171
  // ── Rubric scoring ─────────────────────────────────────────────────────────
125
172
 
126
173
  export type Rubric = {
package/src/mcp.ts CHANGED
@@ -15,8 +15,15 @@ async function importPeer<T>(specifier: string): Promise<T> {
15
15
  return (await import(specifier)) as T;
16
16
  } catch (error) {
17
17
  try {
18
+ // Last-resort fallback to the invoking project's node_modules (the npx
19
+ // landmine: peers there, fullstackgtm in the npx cache). This loads code
20
+ // from the current working directory, so make it VISIBLE — running the
21
+ // MCP server in an untrusted directory could otherwise silently load a
22
+ // malicious `zod`/SDK from its node_modules.
18
23
  const projectRequire = createRequire(join(process.cwd(), "package.json"));
19
- return (await import(pathToFileURL(projectRequire.resolve(specifier)).href)) as T;
24
+ const resolved = projectRequire.resolve(specifier);
25
+ console.error(`fullstackgtm-mcp: loading peer "${specifier}" from the current directory (${resolved}). Only run the MCP server in a directory you trust.`);
26
+ return (await import(pathToFileURL(resolved).href)) as T;
20
27
  } catch {
21
28
  throw error; // the original error carries the missing-peer signal mcp-bin reports on
22
29
  }
package/src/types.ts CHANGED
@@ -343,7 +343,16 @@ export type PatchPlan = {
343
343
  * Unlike per-operation preconditions, this enforces the FULL filter —
344
344
  * negations and relational pseudo-fields included.
345
345
  */
346
- filter?: { objectType: "account" | "contact" | "deal"; where: string[] };
346
+ filter?: {
347
+ objectType: "account" | "contact" | "deal";
348
+ where: string[];
349
+ /**
350
+ * The date the filter's comparison `today` literal resolves to (ISO
351
+ * yyyy-mm-dd). Stored so apply-time re-verification resolves `today`
352
+ * identically to plan time; absent on plans built before comparison ops.
353
+ */
354
+ today?: string;
355
+ };
347
356
  /**
348
357
  * Plan-level guards re-evaluated against a FRESH snapshot at apply time.
349
358
  * If any guard fails, NO operation in the plan is applied. This is how a