fullstackgtm 0.48.0 → 0.49.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 (69) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +7 -2
  4. package/INSTALL_FOR_AGENTS.md +13 -4
  5. package/README.md +12 -1
  6. package/SECURITY.md +27 -3
  7. package/dist/cli/audit.js +10 -7
  8. package/dist/cli/auth.js +8 -4
  9. package/dist/cli/fix.js +20 -7
  10. package/dist/cli/help.js +10 -6
  11. package/dist/cli/market.js +180 -2
  12. package/dist/cli/plans.js +56 -5
  13. package/dist/cli/ui.js +2 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/config.d.ts +13 -1
  16. package/dist/config.js +23 -2
  17. package/dist/connectors/prospectSources.js +4 -11
  18. package/dist/connectors/salesforce.js +12 -5
  19. package/dist/connectors/salesforceAuth.d.ts +9 -0
  20. package/dist/connectors/salesforceAuth.js +47 -5
  21. package/dist/connectors/theirstack.d.ts +0 -19
  22. package/dist/connectors/theirstack.js +3 -10
  23. package/dist/credentials.js +36 -26
  24. package/dist/index.d.ts +3 -2
  25. package/dist/index.js +3 -2
  26. package/dist/market.d.ts +1 -1
  27. package/dist/market.js +7 -127
  28. package/dist/marketClassify.d.ts +10 -0
  29. package/dist/marketClassify.js +52 -1
  30. package/dist/marketSourcing.js +7 -45
  31. package/dist/mcp.js +67 -115
  32. package/dist/planStore.d.ts +28 -1
  33. package/dist/planStore.js +195 -49
  34. package/dist/providerError.d.ts +21 -0
  35. package/dist/providerError.js +30 -0
  36. package/dist/publicHttp.d.ts +28 -0
  37. package/dist/publicHttp.js +143 -0
  38. package/dist/secureFile.d.ts +15 -0
  39. package/dist/secureFile.js +164 -0
  40. package/dist/types.d.ts +1 -1
  41. package/docs/api.md +29 -2
  42. package/docs/architecture.md +13 -2
  43. package/llms.txt +7 -0
  44. package/package.json +5 -3
  45. package/skills/fullstackgtm/SKILL.md +7 -1
  46. package/src/cli/audit.ts +10 -7
  47. package/src/cli/auth.ts +8 -4
  48. package/src/cli/fix.ts +28 -7
  49. package/src/cli/help.ts +10 -6
  50. package/src/cli/market.ts +181 -2
  51. package/src/cli/plans.ts +66 -5
  52. package/src/cli/ui.ts +1 -0
  53. package/src/cli.ts +1 -1
  54. package/src/config.ts +39 -1
  55. package/src/connectors/prospectSources.ts +4 -11
  56. package/src/connectors/salesforce.ts +12 -5
  57. package/src/connectors/salesforceAuth.ts +46 -6
  58. package/src/connectors/theirstack.ts +3 -10
  59. package/src/credentials.ts +47 -28
  60. package/src/index.ts +4 -0
  61. package/src/market.ts +7 -111
  62. package/src/marketClassify.ts +61 -1
  63. package/src/marketSourcing.ts +7 -43
  64. package/src/mcp.ts +93 -136
  65. package/src/planStore.ts +235 -57
  66. package/src/providerError.ts +37 -0
  67. package/src/publicHttp.ts +147 -0
  68. package/src/secureFile.ts +169 -0
  69. package/src/types.ts +1 -0
@@ -0,0 +1,28 @@
1
+ export declare const DEFAULT_PUBLIC_HTTP_MAX_BYTES = 5000000;
2
+ export type PublicAddress = {
3
+ address: string;
4
+ family: number;
5
+ };
6
+ export type PublicLookup = (hostname: string) => Promise<PublicAddress[]>;
7
+ export declare function ipIsPrivate(ip: string): boolean;
8
+ /** Resolve once and reject the entire hostname if any answer is non-public. */
9
+ export declare function resolvePublicAddresses(hostname: string, lookup?: PublicLookup): Promise<PublicAddress[]>;
10
+ export declare function assertPublicUrl(rawUrl: string, lookup?: PublicLookup): Promise<URL>;
11
+ export type PublicHttpResult = {
12
+ status: number;
13
+ headers: Headers;
14
+ body: Uint8Array;
15
+ finalUrl: string;
16
+ };
17
+ type HopResult = Omit<PublicHttpResult, "finalUrl">;
18
+ export type PublicRequestHop = (url: URL, addresses: PublicAddress[], headers: Record<string, string>, timeoutMs: number, maxBytes: number) => Promise<HopResult>;
19
+ /** Public-only GET with DNS pinning, bounded bodies, and per-hop redirect validation. */
20
+ export declare function publicHttpGet(rawUrl: string, options?: {
21
+ headers?: Record<string, string>;
22
+ timeoutMs?: number;
23
+ maxBytes?: number;
24
+ maxRedirects?: number;
25
+ lookup?: PublicLookup;
26
+ requestHop?: PublicRequestHop;
27
+ }): Promise<PublicHttpResult>;
28
+ export {};
@@ -0,0 +1,143 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import { request as httpRequest } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { isIP } from "node:net";
5
+ export const DEFAULT_PUBLIC_HTTP_MAX_BYTES = 5_000_000;
6
+ function ipv4IsPrivate(ip) {
7
+ const parts = ip.split(".").map(Number);
8
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
9
+ return true;
10
+ const [a, b, c] = parts;
11
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) ||
12
+ (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
13
+ (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 192 && b === 168) ||
14
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
15
+ (a === 203 && b === 0 && c === 113) || a >= 224;
16
+ }
17
+ function embeddedIpv4IsPrivate(high, low) {
18
+ const hi = Number.parseInt(high, 16);
19
+ const lo = Number.parseInt(low, 16);
20
+ if (!Number.isInteger(hi) || !Number.isInteger(lo) || hi > 0xffff || lo > 0xffff)
21
+ return true;
22
+ return ipv4IsPrivate(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
23
+ }
24
+ export function ipIsPrivate(ip) {
25
+ const family = isIP(ip);
26
+ if (family === 4)
27
+ return ipv4IsPrivate(ip);
28
+ if (family !== 6)
29
+ return true;
30
+ const lower = ip.toLowerCase();
31
+ if (lower === "::" || lower === "::1")
32
+ return true;
33
+ const embedded = lower.match(/^::(?:ffff:)?([0-9a-f]+):([0-9a-f]+)$/);
34
+ if (embedded)
35
+ return embeddedIpv4IsPrivate(embedded[1], embedded[2]);
36
+ const embeddedDotted = lower.match(/^::(?:ffff:)?(.+\..+)$/);
37
+ if (embeddedDotted)
38
+ return ipv4IsPrivate(embeddedDotted[1]);
39
+ const nat64 = lower.match(/^64:ff9b::([0-9a-f]+):([0-9a-f]+)$/);
40
+ if (nat64)
41
+ return embeddedIpv4IsPrivate(nat64[1], nat64[2]);
42
+ const sixToFour = lower.match(/^2002:([0-9a-f]+):([0-9a-f]+):/);
43
+ if (sixToFour)
44
+ return embeddedIpv4IsPrivate(sixToFour[1], sixToFour[2]);
45
+ return /^(?:fc|fd)/.test(lower) || /^(?:fe8|fe9|fea|feb)/.test(lower) ||
46
+ lower.startsWith("ff") || lower.startsWith("100:") ||
47
+ lower.startsWith("2001:db8:") || lower.startsWith("2001:0:") ||
48
+ lower.startsWith("2001:10:") || lower.startsWith("3fff:") ||
49
+ lower.startsWith("64:ff9b:1:");
50
+ }
51
+ const systemLookup = async (hostname) => (await dnsLookup(hostname, { all: true, verbatim: true })).map(({ address, family }) => ({ address, family }));
52
+ /** Resolve once and reject the entire hostname if any answer is non-public. */
53
+ export async function resolvePublicAddresses(hostname, lookup = systemLookup) {
54
+ const literal = hostname.replace(/^\[|\]$/g, "");
55
+ const family = isIP(literal);
56
+ const addresses = family ? [{ address: literal, family }] : await lookup(literal);
57
+ if (addresses.length === 0)
58
+ throw new Error(`public HTTP: ${literal} did not resolve to an address.`);
59
+ for (const { address } of addresses) {
60
+ if (ipIsPrivate(address))
61
+ throw new Error(`public HTTP refuses ${literal} — it resolves to private/internal address ${address} (SSRF guard).`);
62
+ }
63
+ return addresses;
64
+ }
65
+ export async function assertPublicUrl(rawUrl, lookup = systemLookup) {
66
+ let url;
67
+ try {
68
+ url = new URL(rawUrl);
69
+ }
70
+ catch {
71
+ throw new Error(`market capture: "${rawUrl}" is not a valid URL.`);
72
+ }
73
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
74
+ throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
75
+ }
76
+ const host = url.hostname.replace(/^\[|\]$/g, "");
77
+ if (isIP(host) && ipIsPrivate(host)) {
78
+ throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
79
+ }
80
+ const addresses = await resolvePublicAddresses(host, lookup);
81
+ // Keep the resolved set available to the request path without changing the public API.
82
+ Object.defineProperty(url, "__publicAddresses", { value: addresses });
83
+ return url;
84
+ }
85
+ const requestHop = (url, addresses, headers, timeoutMs, maxBytes) => new Promise((resolve, reject) => {
86
+ let cursor = 0;
87
+ const options = {
88
+ protocol: url.protocol, hostname: url.hostname, port: url.port || undefined,
89
+ path: `${url.pathname}${url.search}`, method: "GET", headers,
90
+ lookup: (_hostname, options, callback) => {
91
+ const requestedFamily = typeof options === "number" ? options : options?.family;
92
+ const eligible = requestedFamily ? addresses.filter((a) => a.family === requestedFamily) : addresses;
93
+ const selected = eligible[cursor++ % eligible.length];
94
+ if (!selected)
95
+ return callback(new Error("public HTTP: no validated address matches the requested family"), "", 0);
96
+ callback(null, selected.address, selected.family);
97
+ },
98
+ };
99
+ const req = (url.protocol === "https:" ? httpsRequest : httpRequest)(options, (res) => {
100
+ if (maxBytes === 0) {
101
+ resolve({ status: res.statusCode ?? 0, headers: new Headers(res.headers), body: new Uint8Array() });
102
+ res.destroy();
103
+ return;
104
+ }
105
+ const chunks = [];
106
+ let total = 0;
107
+ res.on("data", (chunk) => {
108
+ total += chunk.length;
109
+ if (total > maxBytes) {
110
+ req.destroy(new Error(`public HTTP response exceeds ${maxBytes} bytes`));
111
+ return;
112
+ }
113
+ chunks.push(chunk);
114
+ });
115
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: new Headers(res.headers), body: Buffer.concat(chunks) }));
116
+ });
117
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`public HTTP request timed out after ${timeoutMs}ms`)));
118
+ req.on("error", reject);
119
+ req.end();
120
+ });
121
+ const SENSITIVE_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]);
122
+ /** Public-only GET with DNS pinning, bounded bodies, and per-hop redirect validation. */
123
+ export async function publicHttpGet(rawUrl, options = {}) {
124
+ let current = rawUrl;
125
+ let headers = { ...(options.headers ?? {}) };
126
+ const maxRedirects = options.maxRedirects ?? 5;
127
+ for (let hop = 0; hop <= maxRedirects; hop++) {
128
+ const url = await assertPublicUrl(current, options.lookup);
129
+ const addresses = url.__publicAddresses;
130
+ const result = await (options.requestHop ?? requestHop)(url, addresses, headers, options.timeoutMs ?? 15_000, options.maxBytes ?? DEFAULT_PUBLIC_HTTP_MAX_BYTES);
131
+ const location = result.headers.get("location");
132
+ if (result.status >= 300 && result.status < 400 && location) {
133
+ const next = new URL(location, url);
134
+ if (next.origin !== url.origin) {
135
+ headers = Object.fromEntries(Object.entries(headers).filter(([key]) => !SENSITIVE_HEADERS.has(key.toLowerCase())));
136
+ }
137
+ current = next.toString();
138
+ continue;
139
+ }
140
+ return { ...result, finalUrl: current };
141
+ }
142
+ throw new Error(`public HTTP: too many redirects (>${maxRedirects}) for ${rawUrl}`);
143
+ }
@@ -0,0 +1,15 @@
1
+ export declare class UnsafeManagedPathError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare function assertNoSymlinkComponents(path: string): void;
5
+ /** Read a managed file through a no-follow descriptor and require a regular file. */
6
+ export declare function readSecureRegularFile(path: string, options?: {
7
+ tightenMode?: number;
8
+ onModeTightened?: (previousMode: number) => void;
9
+ }): string;
10
+ /**
11
+ * Atomically replace a private file without ever opening the destination.
12
+ * Existing destination and parent symlinks are rejected. The temporary file
13
+ * is created exclusively beside the destination so rename remains atomic.
14
+ */
15
+ export declare function writeSecureFileAtomic(path: string, contents: string | Uint8Array): void;
@@ -0,0 +1,164 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, closeSync, constants, fsyncSync, fchmodSync, fstatSync, lstatSync, openSync, renameSync, unlinkSync, writeFileSync, readFileSync, } from "node:fs";
3
+ import { dirname, isAbsolute, parse, resolve } from "node:path";
4
+ export class UnsafeManagedPathError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "UnsafeManagedPathError";
8
+ }
9
+ }
10
+ export function assertNoSymlinkComponents(path) {
11
+ const absolute = isAbsolute(path) ? path : resolve(path);
12
+ const root = parse(absolute).root;
13
+ const relative = absolute.slice(root.length);
14
+ let current = root;
15
+ for (const component of relative.split(/[\\/]+/).filter(Boolean)) {
16
+ current = resolve(current, component);
17
+ let stat;
18
+ try {
19
+ stat = lstatSync(current);
20
+ }
21
+ catch (error) {
22
+ if (error.code === "ENOENT")
23
+ return;
24
+ throw error;
25
+ }
26
+ if (stat.isSymbolicLink()) {
27
+ throw new UnsafeManagedPathError(`Refusing to use unsafe path containing a symbolic link: ${current}`);
28
+ }
29
+ }
30
+ }
31
+ /** Read a managed file through a no-follow descriptor and require a regular file. */
32
+ export function readSecureRegularFile(path, options = {}) {
33
+ const absolute = resolve(path);
34
+ assertNoSymlinkComponents(dirname(absolute));
35
+ try {
36
+ if (lstatSync(absolute).isSymbolicLink()) {
37
+ throw new UnsafeManagedPathError(`Refusing to read symbolic link: ${absolute}`);
38
+ }
39
+ }
40
+ catch (error) {
41
+ if (error.code !== "ENOENT")
42
+ throw error;
43
+ }
44
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
45
+ let fd;
46
+ try {
47
+ fd = openSync(absolute, constants.O_RDONLY | noFollow);
48
+ const stat = fstatSync(fd);
49
+ if (!stat.isFile()) {
50
+ throw new UnsafeManagedPathError(`Refusing to read non-regular file: ${absolute}`);
51
+ }
52
+ if (options.tightenMode !== undefined) {
53
+ const previousMode = stat.mode & 0o777;
54
+ if ((previousMode & ~options.tightenMode) !== 0) {
55
+ try {
56
+ fchmodSync(fd, options.tightenMode);
57
+ options.onModeTightened?.(previousMode);
58
+ }
59
+ catch {
60
+ // Windows and some non-POSIX filesystems do not implement chmod.
61
+ }
62
+ }
63
+ }
64
+ return readFileSync(fd, "utf8");
65
+ }
66
+ catch (error) {
67
+ if (error.code === "ELOOP") {
68
+ throw new UnsafeManagedPathError(`Refusing to read symbolic link: ${absolute}`);
69
+ }
70
+ throw error;
71
+ }
72
+ finally {
73
+ if (fd !== undefined)
74
+ closeSync(fd);
75
+ }
76
+ }
77
+ /**
78
+ * Atomically replace a private file without ever opening the destination.
79
+ * Existing destination and parent symlinks are rejected. The temporary file
80
+ * is created exclusively beside the destination so rename remains atomic.
81
+ */
82
+ export function writeSecureFileAtomic(path, contents) {
83
+ const destination = resolve(path);
84
+ const parent = dirname(destination);
85
+ assertNoSymlinkComponents(parent);
86
+ try {
87
+ if (lstatSync(destination).isSymbolicLink()) {
88
+ throw new Error(`Refusing to replace symbolic link: ${destination}`);
89
+ }
90
+ }
91
+ catch (error) {
92
+ if (error.code !== "ENOENT")
93
+ throw error;
94
+ }
95
+ let temporary = "";
96
+ let fd;
97
+ try {
98
+ for (let attempt = 0; attempt < 10; attempt++) {
99
+ temporary = `${destination}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`;
100
+ try {
101
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
102
+ fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
103
+ break;
104
+ }
105
+ catch (error) {
106
+ if (error.code !== "EEXIST")
107
+ throw error;
108
+ }
109
+ }
110
+ if (fd === undefined)
111
+ throw new Error(`Unable to create an exclusive temporary file for ${destination}`);
112
+ writeFileSync(fd, contents);
113
+ fsyncSync(fd);
114
+ closeSync(fd);
115
+ fd = undefined;
116
+ try {
117
+ chmodSync(temporary, 0o600);
118
+ }
119
+ catch {
120
+ // Windows and some non-POSIX filesystems do not implement chmod.
121
+ }
122
+ // Recheck immediately before replacement. rename replaces a symlink rather
123
+ // than following it, but rejecting one makes managed-path policy explicit.
124
+ try {
125
+ if (lstatSync(destination).isSymbolicLink()) {
126
+ throw new Error(`Refusing to replace symbolic link: ${destination}`);
127
+ }
128
+ }
129
+ catch (error) {
130
+ if (error.code !== "ENOENT")
131
+ throw error;
132
+ }
133
+ assertNoSymlinkComponents(parent);
134
+ renameSync(temporary, destination);
135
+ temporary = "";
136
+ // Persist the directory entry where supported. Opening/fsyncing a
137
+ // directory is not available on Windows, so this durability enhancement is
138
+ // deliberately best-effort there.
139
+ let directoryFd;
140
+ try {
141
+ directoryFd = openSync(parent, constants.O_RDONLY);
142
+ fsyncSync(directoryFd);
143
+ }
144
+ catch {
145
+ // Unsupported by the platform/filesystem.
146
+ }
147
+ finally {
148
+ if (directoryFd !== undefined)
149
+ closeSync(directoryFd);
150
+ }
151
+ }
152
+ finally {
153
+ if (fd !== undefined)
154
+ closeSync(fd);
155
+ if (temporary) {
156
+ try {
157
+ unlinkSync(temporary);
158
+ }
159
+ catch {
160
+ // Best-effort cleanup after a failed write.
161
+ }
162
+ }
163
+ }
164
+ }
package/dist/types.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  export type CrmProvider = "salesforce" | "hubspot" | "mock" | "unknown" | (string & {});
9
9
  export type RiskLevel = "low" | "medium" | "high";
10
- export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "rejected" | "applied";
10
+ export type ApprovalStatus = "draft" | "needs_approval" | "approved" | "applying" | "rejected" | "applied";
11
11
  export type GtmObjectType = "account" | "contact" | "deal" | "user" | "activity";
12
12
  export type GtmEvidenceSourceSystem = "salesforce" | "hubspot" | "gong" | "chorus" | "fathom" | "manual" | "csv" | "mock" | "web" | "unknown";
13
13
  export type PatchOperationType = "set_field" | "clear_field" | "link_record" | "archive_record" | "create_task" | "create_record" | "merge_records";
package/docs/api.md CHANGED
@@ -62,6 +62,12 @@ release.
62
62
 
63
63
  - `fullstackgtm.config.json`: `{ policy?, rules?: { enabled?, disabled? }, rulePackages? }`.
64
64
  - Rule packages export `rules: GtmAuditRule[]`.
65
+ - Rule packages are executable code and are fail-closed. CLI execution requires
66
+ an explicit `--config <path> --allow-plugins`; `--no-plugins` applies only
67
+ declarative policy/rule filters. An implicitly discovered config never grants
68
+ plugin trust.
69
+ - MCP never executes rule packages. Mutable tool-call paths are not accepted as
70
+ a durable code-trust boundary; use the reviewed CLI plugin flow instead.
65
71
  - Precedence: CLI flags > config file > defaults.
66
72
 
67
73
  ## CLI
@@ -283,6 +289,23 @@ domain stamped — so the acquired account is immediately signal-watchable
283
289
  `CanonicalContact.linkedin` (mapped from HubSpot `hs_linkedin_url`) is the
284
290
  strong dedup key across all of the above.
285
291
 
292
+ ## Apply recovery
293
+
294
+ Store-backed applies persist an apply-attempt claim before provider I/O. If the
295
+ process exits without a completed run, the plan remains `applying`: the CLI
296
+ cannot know whether a remote write landed and will not replay it. Inspect the
297
+ attempt with `fullstackgtm plans show <id>` and reconcile the named provider's
298
+ records. Only then run:
299
+
300
+ ```bash
301
+ fullstackgtm plans recover <id> --acknowledge-uncertain-writes
302
+ ```
303
+
304
+ Recovery performs no provider calls. It clears the old approvals and returns
305
+ the plan to `needs_approval`, requiring a fresh review and signed approval
306
+ before another apply. Provider APIs do not all expose idempotency keys, so this
307
+ operator reconciliation remains necessary after a timeout or crash.
308
+
286
309
  ## Schedule
287
310
 
288
311
  The horizontal scheduler: a declarative schedule-entry store, a
@@ -347,8 +370,12 @@ the stored capture it cites before a set is accepted; failed captures read as
347
370
  Tools: `fullstackgtm_capabilities` (the machine-readable server contract —
348
371
  tool inventory with per-tool CRM-write flags, derived from the server's own
349
372
  registration table), `fullstackgtm_audit`, `fullstackgtm_rules`,
350
- `fullstackgtm_apply` (requires explicit `approvedOperationIds`),
373
+ `fullstackgtm_apply` (accepts only a stored `planId`; approved operation ids and
374
+ placeholder values come exclusively from the HMAC-signed plan store),
351
375
  `fullstackgtm_suggest`, `fullstackgtm_call_parse`, `fullstackgtm_resolve`,
352
376
  `fullstackgtm_market_worksheet`, `fullstackgtm_market_observe` (validates,
353
377
  verifies quoted spans against the stored captures, appends, returns front
354
- states). Input schemas are stable.
378
+ states). Input schemas are stable. MCP never executes rule packages and cannot
379
+ accept external plan files or caller-supplied approvals/value overrides. An
380
+ uncertain apply is reconciled with the CLI `plans recover` workflow and is
381
+ never replayed automatically.
@@ -27,14 +27,22 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
27
27
  plans approve (planStore.ts + integrity.ts: HMAC-signed)
28
28
 
29
29
 
30
+ atomic apply claim + durable attempt journal
31
+
32
+
30
33
  applyPatchPlan (connector.ts): for each APPROVED op —
31
34
  compare-and-set vs live value, precondition + guard recheck,
32
35
  irreversible-op drift guard → connector.applyOperation()
33
36
 
34
37
 
35
- PatchPlanRun (the audit record; auditLog.ts exports it)
38
+ PatchPlanRun (claim-bound audit record)
36
39
  ```
37
40
 
41
+ An interrupted provider attempt stays `applying`/uncertain. Recovery never
42
+ replays automatically: reconcile provider state, then `plans recover ...
43
+ --acknowledge-uncertain-writes` clears approvals and returns the plan to
44
+ `needs_approval` for a fresh signed review.
45
+
38
46
  ## Module map (`src/`)
39
47
 
40
48
  **Contracts & model**
@@ -49,12 +57,15 @@ provider ──fetchSnapshot()──► CanonicalGtmSnapshot
49
57
  - `audit.ts` — runs rules over a snapshot into a `PatchPlan`.
50
58
  - `suggest.ts` — derives concrete values for `requires_human_*` placeholders from
51
59
  snapshot evidence, with confidence + reasons.
52
- - `planStore.ts` — durable plan lifecycle (save / approve / record runs), 0600.
60
+ - `planStore.ts` — durable plan lifecycle (save / approve / atomic claim /
61
+ attempt journal / no-replay recovery / claim-bound runs), atomic 0600 files.
53
62
  - `integrity.ts` — HMAC-signs approvals (per-install key) and verifies at apply.
54
63
  - `connector.ts` — `applyPatchPlan`: the safety contract (approval filter,
55
64
  placeholder refusal, CAS, precondition/guard recheck, irreversible-op drift
56
65
  guard). Provider-agnostic.
57
66
  - `auditLog.ts` — hash-chained, signed export of every apply run.
67
+ - `secureFile.ts` — atomic no-follow sensitive-file reads/writes.
68
+ - `publicHttp.ts` — DNS-pinned, redirect-validating, bounded public-only HTTP.
58
69
 
59
70
  **Governed write verbs (each builds a plan; never writes directly)**
60
71
  - `bulkUpdate.ts`, `dedupe.ts`, `reassign.ts`, `route.ts` — filtered,
package/llms.txt CHANGED
@@ -10,6 +10,13 @@ CLI quick check: `fullstackgtm doctor --json`. Zero-credential demo:
10
10
  explicitly approved operation ids. Exit codes: 0 success, 1 error, 2 findings
11
11
  at/above `--fail-on`.
12
12
 
13
+ MCP apply accepts only a stored `planId`; approvals and concrete values come
14
+ from the HMAC-signed plan store, never the tool caller. MCP never executes rule
15
+ packages or external plans. Store-backed apply is single-owner and journaled;
16
+ an interrupted provider attempt remains uncertain and is never auto-replayed.
17
+ After reconciling provider state, `plans recover <id>
18
+ --acknowledge-uncertain-writes` clears approvals for a fresh human review.
19
+
13
20
  ## Docs
14
21
 
15
22
  - [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.48.0",
3
+ "version": "0.49.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 LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",
@@ -45,10 +45,12 @@
45
45
  "DATA-FLOWS.md"
46
46
  ],
47
47
  "scripts": {
48
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
48
+ "clean": "node scripts/clean.mjs",
49
+ "build": "npm run clean && tsc -p tsconfig.build.json",
49
50
  "pretest": "test -d tests || { echo 'No tests/ in this directory. In the published mirror, tests live here and `npm test` works. In the monorepo, run package tests from the repo ROOT: `node --experimental-strip-types --test tests/fullstackgtm*.test.ts` (the tests live at the monorepo root tests/).' >&2; exit 1; }",
50
51
  "test": "node --experimental-strip-types --test tests/*.test.ts",
51
- "prepublishOnly": "npm run build && npm pack --dry-run && node dist/bin.js --help && node dist/mcp-bin.js --help"
52
+ "verify:package": "npm run build && node scripts/verify-packed-install.mjs --with-peers",
53
+ "prepublishOnly": "npm run verify:package"
52
54
  },
53
55
  "devDependencies": {
54
56
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -85,10 +85,16 @@ npx -y fullstackgtm-mcp
85
85
 
86
86
  Tools over stdio: `fullstackgtm_audit` (read-only), `fullstackgtm_rules`,
87
87
  `fullstackgtm_suggest`, `fullstackgtm_call_parse`,
88
- `fullstackgtm_apply` (requires `approvedOperationIds`),
88
+ `fullstackgtm_apply` (stored `planId` only; approvals and values come from the
89
+ HMAC-signed plan store and cannot be supplied by the MCP caller),
89
90
  `fullstackgtm_resolve`, `fullstackgtm_market_worksheet`,
90
91
  `fullstackgtm_market_observe`.
91
92
 
93
+ MCP never executes rule packages or external plans. An uncertain apply is
94
+ never replayed automatically: reconcile provider state, then use
95
+ `fullstackgtm plans recover <id> --acknowledge-uncertain-writes`, which clears
96
+ approvals and requires a fresh review.
97
+
92
98
  ## Composing the primitives into plays
93
99
 
94
100
  This CLI ships governed **primitives** — there is no `outbound` mega-command.
package/src/cli/audit.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { readFileSync, writeFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { auditSnapshot, defaultPolicy } from "../audit.ts";
6
- import { loadConfig, mergePolicy, resolveConfiguredRules } from "../config.ts";
6
+ import { loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli } from "../config.ts";
7
7
  import { getCredential, resolveHubspotConnection } from "../credentials.ts";
8
8
  import { patchPlanToMarkdown } from "../format.ts";
9
9
  import { appendHealthEntry, computeHealth, healthToMarkdown, readHealthTimeline, saveWorkspaceSnapshot, summarizeHealth, activeWorkspaceProfile, type HealthRollup } from "../health.ts";
@@ -174,8 +174,9 @@ async function registerHubspotWithBroker(): Promise<void> {
174
174
 
175
175
  export async function audit(args: string[]) {
176
176
  const threshold = failOnThreshold(args);
177
- const loaded = loadConfig(option(args, "--config") ?? undefined);
178
- const rules = selectedRules(args, await resolveConfiguredRules(loaded));
177
+ const explicitConfig = option(args, "--config") ?? undefined;
178
+ const loaded = loadConfig(explicitConfig);
179
+ const rules = selectedRules(args, await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig)));
179
180
  const snapshot = await readSnapshot(args);
180
181
 
181
182
  const policy = mergePolicy(defaultPolicy(), loaded?.config);
@@ -371,8 +372,9 @@ function shortDay(at: string): string {
371
372
  * re-fetching (useful for a plan produced earlier or by another machine).
372
373
  */
373
374
  export async function reportCommand(args: string[]) {
374
- const loaded = loadConfig(option(args, "--config") ?? undefined);
375
- const configuredRules = await resolveConfiguredRules(loaded);
375
+ const explicitConfig = option(args, "--config") ?? undefined;
376
+ const loaded = loadConfig(explicitConfig);
377
+ const configuredRules = await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig));
376
378
 
377
379
  let plan: PatchPlan;
378
380
  let snapshot: CanonicalGtmSnapshot | undefined;
@@ -424,8 +426,9 @@ export async function reportCommand(args: string[]) {
424
426
  }
425
427
 
426
428
  export async function rulesCommand(args: string[]) {
427
- const loaded = loadConfig(option(args, "--config") ?? undefined);
428
- const rules = await resolveConfiguredRules(loaded);
429
+ const explicitConfig = option(args, "--config") ?? undefined;
430
+ const loaded = loadConfig(explicitConfig);
431
+ const rules = await resolveConfiguredRules(loaded, undefined, rulePackageTrustFromCli(args, explicitConfig));
429
432
  if (args.includes("--json")) {
430
433
  console.log(
431
434
  JSON.stringify(
package/src/cli/auth.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { existsSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { DEFAULT_LOOPBACK_PORT, openInBrowser, runHubspotLoopbackLogin, validateHubspotToken } from "../connectors/hubspotAuth.ts";
6
- import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceToken } from "../connectors/salesforceAuth.ts";
6
+ import { pollSalesforceDeviceLogin, startSalesforceDeviceLogin, validateSalesforceOrigin, validateSalesforceToken } from "../connectors/salesforceAuth.ts";
7
7
  import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, storeCredential, type StoredCredential } from "../credentials.ts";
8
8
  import { activeWorkspaceProfile, readHealthTimeline, summarizeHealth } from "../health.ts";
9
9
  import { resolveLlmCredential, validateLlmKey } from "../llm.ts";
@@ -252,7 +252,10 @@ async function salesforceLogin(args: string[]) {
252
252
  await guidedProviderLogin("salesforce", args, "--device requires --client-id (the consumer key of a Connected App with device flow enabled).");
253
253
  return;
254
254
  }
255
- const loginUrl = option(args, "--login-url") ?? undefined;
255
+ const loginUrlInput = option(args, "--login-url") ?? undefined;
256
+ const loginUrl = loginUrlInput
257
+ ? validateSalesforceOrigin(loginUrlInput, "Salesforce --login-url")
258
+ : undefined;
256
259
  const authorization = await startSalesforceDeviceLogin({ clientId, loginUrl });
257
260
  console.error(
258
261
  `\nPairing code: ${authorization.userCode}\n\nConfirm this code at:\n\n ${authorization.verificationUri}\n`,
@@ -282,8 +285,8 @@ async function salesforceLogin(args: string[]) {
282
285
  return;
283
286
  }
284
287
 
285
- const instanceUrl = option(args, "--instance-url");
286
- if (!instanceUrl) {
288
+ const instanceUrlInput = option(args, "--instance-url");
289
+ if (!instanceUrlInput) {
287
290
  await guidedProviderLogin(
288
291
  "salesforce",
289
292
  args,
@@ -291,6 +294,7 @@ async function salesforceLogin(args: string[]) {
291
294
  );
292
295
  return;
293
296
  }
297
+ const instanceUrl = validateSalesforceOrigin(instanceUrlInput, "Salesforce --instance-url");
294
298
  const token = await readSecret("Salesforce access token");
295
299
  if (!token) throw new Error("No access token provided.");
296
300
  if (!args.includes("--no-validate")) {