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.
- package/CHANGELOG.md +83 -0
- package/DATA-FLOWS.md +52 -0
- package/NOTICE +5 -0
- package/README.md +18 -2
- package/SECURITY.md +82 -0
- package/dist/auditLog.d.ts +58 -0
- package/dist/auditLog.js +112 -0
- package/dist/bulkUpdate.d.ts +16 -4
- package/dist/bulkUpdate.js +209 -10
- package/dist/cli.d.ts +8 -1
- package/dist/cli.js +93 -1
- package/dist/connector.js +6 -2
- package/dist/credentials.js +85 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/keychain.d.ts +30 -0
- package/dist/keychain.js +85 -0
- package/dist/llm.js +48 -0
- package/dist/mcp.js +8 -1
- package/dist/types.d.ts +6 -0
- package/docs/api.md +5 -2
- package/llms.txt +7 -1
- package/package.json +7 -4
- package/src/auditLog.ts +173 -0
- package/src/bulkUpdate.ts +226 -10
- package/src/cli.ts +90 -1
- package/src/connector.ts +6 -2
- package/src/credentials.ts +82 -11
- package/src/index.ts +7 -0
- package/src/keychain.ts +112 -0
- package/src/llm.ts +47 -0
- package/src/mcp.ts +8 -1
- package/src/types.ts +10 -1
package/src/keychain.ts
ADDED
|
@@ -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
|
-
|
|
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?: {
|
|
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
|