fullstackgtm 0.27.0 → 0.28.1

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/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