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,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/llm.js CHANGED
@@ -70,8 +70,23 @@ export async function extractInsightsLlm(transcript, options) {
70
70
  const text = truncateTranscript(transcript);
71
71
  const prompt = `${EXTRACT_INSTRUCTIONS}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
72
72
  const result = (await forcedToolCall(prompt, "extract_call_insights", EXTRACT_SCHEMA, model, options));
73
+ const normalizedTranscript = normalizeSpan(text);
73
74
  const insights = (result.insights ?? [])
74
75
  .filter((insight) => INSIGHT_TYPES.includes(insight.type))
76
+ // Mechanical verbatim gate (mirrors market classify): the prompt asks for a
77
+ // verbatim quote, but a prompt-injected or hallucinated transcript could
78
+ // fabricate a grounded-looking insight that drives a governed writeback.
79
+ // (1) The evidence quote must be a non-trivial verbatim span of the transcript.
80
+ .filter((insight) => {
81
+ const quote = normalizeSpan(insight.evidence ?? "");
82
+ return quote.length >= 12 && normalizedTranscript.includes(quote);
83
+ })
84
+ // (2) For next_step — the only insight type whose `text` is WRITTEN to the CRM
85
+ // (set_field nextStep / create_task body) — the written action must itself be
86
+ // grounded in the verified quote, not just accompanied by an innocuous one.
87
+ // This closes the decoupling attack: a prompt-injected transcript that emits a
88
+ // malicious `text` while quoting an unrelated real span no longer survives.
89
+ .filter((insight) => insight.type !== "next_step" || actionGroundedInEvidence(insight.text, insight.evidence ?? ""))
75
90
  .map((insight) => ({
76
91
  ...insight,
77
92
  title: insight.type.replace(/_/g, " "),
@@ -81,6 +96,39 @@ export async function extractInsightsLlm(transcript, options) {
81
96
  .sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
82
97
  return { insights, model };
83
98
  }
99
+ /** Whitespace/punctuation-spacing-normalized match (same rule as market spans). */
100
+ function normalizeSpan(value) {
101
+ return value
102
+ .replace(/\s+([.,;:!?])/g, "$1")
103
+ .replace(/\s+/g, " ")
104
+ .trim()
105
+ .toLowerCase();
106
+ }
107
+ /**
108
+ * Is the written next-step action grounded in its (already transcript-verified)
109
+ * evidence quote? A legitimate next step paraphrases the quote, so it reuses the
110
+ * quote's salient terms; a prompt-injected action ("wire $50,000 to account
111
+ * 1234") quoting an unrelated innocuous span does not. Two checks: every
112
+ * number/amount in the action must appear in the evidence (defeats the
113
+ * financial-exfil class cleanly), and a meaningful share of the action's
114
+ * distinctive (≥4-char) words must appear in the evidence.
115
+ */
116
+ function actionGroundedInEvidence(text, evidence) {
117
+ const action = normalizeSpan(text);
118
+ const quote = normalizeSpan(evidence);
119
+ if (!action)
120
+ return false;
121
+ const numbers = action.match(/\d[\d,.]*/g) ?? [];
122
+ for (const n of numbers) {
123
+ if (!quote.includes(n))
124
+ return false; // an ungrounded amount/account/id is a red flag
125
+ }
126
+ const distinctive = [...new Set(action.split(/[^a-z0-9$]+/).filter((token) => token.length >= 4))];
127
+ if (distinctive.length === 0)
128
+ return true; // nothing distinctive to ground (a short generic step)
129
+ const grounded = distinctive.filter((token) => quote.includes(token)).length;
130
+ return grounded / distinctive.length >= 0.4;
131
+ }
84
132
  export const DEFAULT_RUBRIC = {
85
133
  scale: 5,
86
134
  dimensions: [
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,9 +1,9 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.26.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
- "author": "Full Stack GTM",
6
+ "author": "Full Stack GTM <security@fullstackgtm.com> (https://fullstackgtm.com)",
7
7
  "homepage": "https://github.com/fullstackgtm/core#readme",
8
8
  "bugs": {
9
9
  "url": "https://github.com/fullstackgtm/core/issues"
@@ -31,10 +31,13 @@
31
31
  "INSTALL_FOR_AGENTS.md",
32
32
  "llms.txt",
33
33
  "skills",
34
- "LICENSE"
34
+ "LICENSE",
35
+ "NOTICE",
36
+ "SECURITY.md",
37
+ "DATA-FLOWS.md"
35
38
  ],
36
39
  "scripts": {
37
- "build": "tsc -p tsconfig.build.json",
40
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
38
41
  "test": "node --experimental-strip-types --test tests/*.test.ts",
39
42
  "prepublishOnly": "npm run build"
40
43
  },
@@ -0,0 +1,173 @@
1
+ import { createHash, createHmac } from "node:crypto";
2
+ import { loadOrCreateSigningKey, loadSigningKey } from "./integrity.ts";
3
+ import type { PatchPlanRun } from "./types.ts";
4
+ import type { StoredPlan } from "./planStore.ts";
5
+
6
+ /**
7
+ * Exportable, tamper-evident audit log.
8
+ *
9
+ * Every apply run is already recorded per-plan in the store, but a compliance /
10
+ * change-management process needs ONE portable artifact it can archive and
11
+ * later prove was not edited. `audit-log export` flattens every run across all
12
+ * plans into a hash-chained sequence: each entry carries the hash of the
13
+ * previous entry, so removing, reordering, or editing any entry breaks the
14
+ * chain at that point and `audit-log verify` reports exactly where. When a
15
+ * per-install signing key exists, the chain head is also HMAC-signed, so the
16
+ * export can be attributed to this installation, not just shown internally
17
+ * consistent.
18
+ *
19
+ * This is a point-in-time attestation of the stored run history; it is not a
20
+ * real-time append-only journal (that is future work). It answers "give me an
21
+ * auditable record of every change this tool applied, that my auditor can
22
+ * verify hasn't been doctored."
23
+ */
24
+
25
+ export type AuditLogEntry = {
26
+ seq: number;
27
+ planId: string;
28
+ planTitle: string;
29
+ provider: string;
30
+ startedAt: string;
31
+ finishedAt: string;
32
+ status: PatchPlanRun["status"];
33
+ trigger: string;
34
+ /** operationId → status, the per-operation outcome of this run */
35
+ operations: Array<{ operationId: string; status: string; detail?: string }>;
36
+ prevHash: string;
37
+ hash: string;
38
+ };
39
+
40
+ export type AuditLogExport = {
41
+ version: 1;
42
+ generatedAt: string;
43
+ entryCount: number;
44
+ chainHead: string;
45
+ /** HMAC of chainHead with the per-install key, or null when no key exists. */
46
+ signature: string | null;
47
+ entries: AuditLogEntry[];
48
+ };
49
+
50
+ const GENESIS = "0".repeat(64);
51
+
52
+ /** The content that the chain hash covers — everything but prevHash/hash. */
53
+ function entryContent(entry: Omit<AuditLogEntry, "prevHash" | "hash">): string {
54
+ return JSON.stringify([
55
+ entry.seq,
56
+ entry.planId,
57
+ entry.planTitle,
58
+ entry.provider,
59
+ entry.startedAt,
60
+ entry.finishedAt,
61
+ entry.status,
62
+ entry.trigger,
63
+ entry.operations,
64
+ ]);
65
+ }
66
+
67
+ function chainHash(prevHash: string, content: string): string {
68
+ return createHash("sha256").update(prevHash).update("\n").update(content).digest("hex");
69
+ }
70
+
71
+ /** Flatten all runs from the stored plans, oldest first, into chained entries. */
72
+ export function buildAuditLog(plans: StoredPlan[], generatedAt: string): AuditLogExport {
73
+ const runs: Array<{ stored: StoredPlan; run: PatchPlanRun }> = [];
74
+ for (const stored of plans) {
75
+ for (const run of stored.runs ?? []) runs.push({ stored, run });
76
+ }
77
+ runs.sort((a, b) => a.run.finishedAt.localeCompare(b.run.finishedAt));
78
+
79
+ const entries: AuditLogEntry[] = [];
80
+ let prevHash = GENESIS;
81
+ runs.forEach(({ stored, run }, index) => {
82
+ const base = {
83
+ seq: index,
84
+ planId: run.planId,
85
+ planTitle: stored.plan.title,
86
+ provider: run.provider,
87
+ startedAt: run.startedAt,
88
+ finishedAt: run.finishedAt,
89
+ status: run.status,
90
+ trigger: (run as { trigger?: string }).trigger ?? "manual",
91
+ operations: run.results.map((result) => ({
92
+ operationId: result.operationId,
93
+ status: result.status,
94
+ ...(result.detail ? { detail: result.detail } : {}),
95
+ })),
96
+ };
97
+ const hash = chainHash(prevHash, entryContent(base));
98
+ entries.push({ ...base, prevHash, hash });
99
+ prevHash = hash;
100
+ });
101
+
102
+ // Always sign — an unsigned export's keyless sha256 chain is self-recomputable
103
+ // (an attacker can edit entries and rebuild the chain from the public genesis),
104
+ // so the per-install HMAC is the only real tamper barrier. Bind the header
105
+ // fields into the signed material so metadata can't be altered either.
106
+ const key = loadOrCreateSigningKey();
107
+ const entryCount = entries.length;
108
+ return {
109
+ version: 1,
110
+ generatedAt,
111
+ entryCount,
112
+ chainHead: prevHash,
113
+ signature: signHead(key, 1, generatedAt, entryCount, prevHash),
114
+ entries,
115
+ };
116
+ }
117
+
118
+ function signHead(key: Buffer, version: number, generatedAt: string, entryCount: number, chainHead: string): string {
119
+ return createHmac("sha256", key).update(JSON.stringify([version, generatedAt, entryCount, chainHead])).digest("hex");
120
+ }
121
+
122
+ export type AuditLogVerification = {
123
+ ok: boolean;
124
+ /** seq of the first entry whose hash does not verify, or null if the chain holds */
125
+ brokenAt: number | null;
126
+ signatureOk: boolean | null; // null = no signature present / no key to check
127
+ detail: string;
128
+ };
129
+
130
+ /** Recompute the chain (and the signature if a key is available). */
131
+ export function verifyAuditLog(log: AuditLogExport): AuditLogVerification {
132
+ let prevHash = GENESIS;
133
+ for (const entry of log.entries) {
134
+ if (entry.prevHash !== prevHash) {
135
+ return { ok: false, brokenAt: entry.seq, signatureOk: null, detail: `Chain breaks at entry ${entry.seq}: prevHash does not match the previous entry's hash (an entry was removed, reordered, or edited).` };
136
+ }
137
+ const expected = chainHash(prevHash, entryContent(entry));
138
+ if (expected !== entry.hash) {
139
+ return { ok: false, brokenAt: entry.seq, signatureOk: null, detail: `Chain breaks at entry ${entry.seq}: its content was edited after export (hash mismatch).` };
140
+ }
141
+ prevHash = entry.hash;
142
+ }
143
+ if (prevHash !== log.chainHead) {
144
+ return { ok: false, brokenAt: log.entries.length, signatureOk: null, detail: "The recorded chainHead does not match the recomputed chain." };
145
+ }
146
+ // The keyless chain alone is self-recomputable, so a missing/stripped signature
147
+ // means the export is forgeable — refuse it. (Current exports are always
148
+ // signed; a null signature is an old/unsigned or a downgraded export.)
149
+ if (!log.signature) {
150
+ return {
151
+ ok: false,
152
+ brokenAt: null,
153
+ signatureOk: false,
154
+ detail: "Unsigned export: the hash chain alone is self-recomputable, so this log cannot be trusted (the signature is absent or was stripped). Re-export on the issuing install.",
155
+ };
156
+ }
157
+ const key = loadSigningKey();
158
+ if (!key) {
159
+ // A third party without the issuing install's key cannot verify attribution.
160
+ // The chain is internally consistent, but that is not proof of authenticity.
161
+ return {
162
+ ok: false,
163
+ brokenAt: null,
164
+ signatureOk: null,
165
+ detail: "Chain is internally consistent, but this machine has no signing key to verify the signature — authenticity is unattributed. Verify on the issuing install.",
166
+ };
167
+ }
168
+ const signatureOk = signHead(key, log.version, log.generatedAt, log.entryCount, prevHash) === log.signature;
169
+ if (!signatureOk) {
170
+ return { ok: false, brokenAt: null, signatureOk: false, detail: "Signature does not match this installation's key — the log was exported elsewhere, or its entries/metadata were altered after signing." };
171
+ }
172
+ return { ok: true, brokenAt: null, signatureOk: true, detail: `Verified ${log.entries.length} entries; chain intact and signature valid.` };
173
+ }