fullstackgtm 0.25.0 → 0.25.2

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.
@@ -28,6 +28,22 @@ function escapeHtml(value) {
28
28
  .replace(/>/g, ">")
29
29
  .replace(/"/g, """);
30
30
  }
31
+ /**
32
+ * Serialize JSON for embedding inside an inline <script> block. JSON.stringify
33
+ * does not escape `<`, `>`, `&`, or the U+2028/U+2029 line separators, so a
34
+ * vendor name containing `</script>` (these are untrusted, competitor-authored
35
+ * strings) would close the tag and inject markup. Replacing them with their
36
+ * \uXXXX escapes keeps the parsed value identical while making the breakout
37
+ * sequence unrepresentable in the HTML source.
38
+ */
39
+ export function safeJsonForScript(value) {
40
+ return JSON.stringify(value)
41
+ .replace(/</g, "\\u003c")
42
+ .replace(/>/g, "\\u003e")
43
+ .replace(/&/g, "\\u0026")
44
+ .replace(/\u2028/g, "\\u2028")
45
+ .replace(/\u2029/g, "\\u2029");
46
+ }
31
47
  function buildModel(config, set) {
32
48
  const fronts = computeFrontStates(config, set);
33
49
  const stateByClaim = new Map(fronts.map((front) => [front.claimId, front.state]));
@@ -320,7 +336,7 @@ function axisSectionsHtml(config, set) {
320
336
  <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
321
337
  </div>
322
338
  <div class="map-tip" id="map-tip" hidden></div>
323
- <script type="application/json" id="map-data">${JSON.stringify(tipData)}</script>
339
+ <script type="application/json" id="map-data">${safeJsonForScript(tipData)}</script>
324
340
  <script>
325
341
  (function () {
326
342
  var data = JSON.parse(document.getElementById("map-data").textContent);
@@ -331,7 +347,16 @@ function axisSectionsHtml(config, set) {
331
347
  function show(v, evt) {
332
348
  var d = data[v];
333
349
  if (!d) return;
334
- tip.innerHTML = "<b>" + d.n + " · " + d.name + "</b>" + d.lines.map(function (l) { return "<div>" + l + "</div>"; }).join("");
350
+ // textContent only vendor names / axis labels are untrusted (competitor-controlled).
351
+ tip.textContent = "";
352
+ var head = document.createElement("b");
353
+ head.textContent = d.n + " · " + d.name;
354
+ tip.appendChild(head);
355
+ d.lines.forEach(function (l) {
356
+ var div = document.createElement("div");
357
+ div.textContent = l;
358
+ tip.appendChild(div);
359
+ });
335
360
  tip.hidden = false;
336
361
  var box = fig.getBoundingClientRect();
337
362
  tip.style.left = Math.min(evt.clientX - box.left + 14, box.width - tip.offsetWidth - 8) + "px";
@@ -419,7 +444,7 @@ export function marketMapToHtml(config, set) {
419
444
  const anchorLoud = anchor
420
445
  ? claimIds.filter((claimId) => model.cell(anchor, claimId)?.intensity === "loud").length
421
446
  : 0;
422
- const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
447
+ const anchorNote = anchor ? ` · ${e(vendorNamesById.get(anchor) ?? anchor)} loud on ${anchorLoud}` : "";
423
448
  return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
424
449
  <table><thead><tr><th></th>${vendorHeads}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
425
450
  </details>`;
@@ -475,7 +500,7 @@ export function marketMapToHtml(config, set) {
475
500
  const obs = model.cell(vendor.id, claimId);
476
501
  if (!obs || obs.evidence.length === 0)
477
502
  return [];
478
- return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
503
+ return obs.evidence.map((evidence) => `<div class="ev"><span class="ev-head">${e(claimId)} · ${e(obs.intensity.toUpperCase())} (${e(String(obs.confidence ?? ""))})</span>` +
479
504
  `<blockquote>“${e(evidence.text)}”</blockquote>` +
480
505
  `<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`);
481
506
  });
@@ -53,6 +53,23 @@ export declare function scheduleId(label: string, cron: string, argv: string[],
53
53
  * in-process into the CLI router, never through a shell).
54
54
  */
55
55
  export declare function validateSchedulableArgv(argv: string[]): void;
56
+ /**
57
+ * A schedule label is free text the operator chooses, but it is later
58
+ * interpolated into a crontab comment line by `renderManagedBlock`. A newline
59
+ * (or carriage return) would break out of the comment and inject an arbitrary
60
+ * crontab entry on `schedule install`. Reject control characters at the entry
61
+ * point so a label can never carry a second line; `renderManagedBlock` also
62
+ * strips them defensively in case a hand-edited schedules.json slips one past.
63
+ */
64
+ export declare function assertSingleLineLabel(label: string): void;
65
+ /**
66
+ * True if the string contains any line-breaking or control character. Covers
67
+ * C0 controls + DEL, plus the Unicode separators a non-cron parser might honor
68
+ * (NEL U+0085, LS U+2028, PS U+2029, VT U+000B, FF U+000C) — defense-in-depth
69
+ * for the future modal/aws scaffold renderers whose target formats may treat
70
+ * those as line breaks.
71
+ */
72
+ export declare function hasControlChar(value: string): boolean;
56
73
  /**
57
74
  * Split a `schedule add "<command>"` string into argv, honoring single and
58
75
  * double quotes (no escapes, no expansion — this is tokenization, not shell).
package/dist/schedule.js CHANGED
@@ -77,6 +77,68 @@ export function validateSchedulableArgv(argv) {
77
77
  }
78
78
  }
79
79
  }
80
+ /**
81
+ * A schedule label is free text the operator chooses, but it is later
82
+ * interpolated into a crontab comment line by `renderManagedBlock`. A newline
83
+ * (or carriage return) would break out of the comment and inject an arbitrary
84
+ * crontab entry on `schedule install`. Reject control characters at the entry
85
+ * point so a label can never carry a second line; `renderManagedBlock` also
86
+ * strips them defensively in case a hand-edited schedules.json slips one past.
87
+ */
88
+ export function assertSingleLineLabel(label) {
89
+ if (hasControlChar(label)) {
90
+ throw new Error("A schedule --label cannot contain newlines or control characters " +
91
+ "(they would inject lines into the managed crontab block). Use a plain single-line name.");
92
+ }
93
+ }
94
+ /**
95
+ * True if the string contains any line-breaking or control character. Covers
96
+ * C0 controls + DEL, plus the Unicode separators a non-cron parser might honor
97
+ * (NEL U+0085, LS U+2028, PS U+2029, VT U+000B, FF U+000C) — defense-in-depth
98
+ * for the future modal/aws scaffold renderers whose target formats may treat
99
+ * those as line breaks.
100
+ */
101
+ export function hasControlChar(value) {
102
+ for (let i = 0; i < value.length; i++) {
103
+ const code = value.charCodeAt(i);
104
+ if (code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029)
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+ /** Collapse any control/separator character to a space — last-resort guard at render time. */
110
+ function sanitizeCrontabComment(value) {
111
+ let out = "";
112
+ for (const ch of value) {
113
+ const code = ch.charCodeAt(0);
114
+ out += code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029 ? " " : ch;
115
+ }
116
+ return out.replace(/ {2,}/g, " ").trim();
117
+ }
118
+ /**
119
+ * Validate every field of an entry that `renderManagedBlock` interpolates into
120
+ * the crontab — not just the label. The EXECUTABLE line embeds `cron` and `id`
121
+ * raw, and `schedule install` renders entries straight from schedules.json, so
122
+ * a hand-edited (or otherwise tampered) entry with a newline in cron/id/profile
123
+ * would inject a live crontab line. Refuse to render a tampered entry rather
124
+ * than emit it. (Well-formed entries never trip this: cron is parser-validated,
125
+ * id is an fnv1a hex hash, label is guarded at add-time.)
126
+ */
127
+ function assertRenderableEntry(profile, entry) {
128
+ const fields = [
129
+ ["profile", profile],
130
+ ["cron", entry.cron],
131
+ ["id", entry.id],
132
+ ["label", entry.label],
133
+ ...entry.argv.map((token, i) => [`argv[${i}]`, token]),
134
+ ];
135
+ for (const [name, value] of fields) {
136
+ if (hasControlChar(value)) {
137
+ throw new Error(`Refusing to render schedule entry ${entry.id}: its ${name} contains a newline or control character. ` +
138
+ "The schedules.json store has been tampered with or corrupted — repair it before installing.");
139
+ }
140
+ }
141
+ }
80
142
  /**
81
143
  * Split a `schedule add "<command>"` string into argv, honoring single and
82
144
  * double quotes (no escapes, no expansion — this is tokenization, not shell).
@@ -124,7 +186,13 @@ const CRON_FIELD_SPECS = [
124
186
  { name: "day-of-week", min: 0, max: 7 },
125
187
  ];
126
188
  export function parseCron(expression) {
127
- const fields = expression.trim().split(/\s+/);
189
+ // Reject non-ASCII whitespace and control chars: JS \s splits on U+00A0,
190
+ // U+3000, etc., but Vixie cron's field separator is only space/tab. A source
191
+ // carrying them would parse here yet be misparsed or rejected by `crontab -`.
192
+ if (hasControlChar(expression) || /[^\x20-\x7e]/.test(expression)) {
193
+ throw new Error(`Invalid cron expression "${expression}": only ASCII characters, space, and tab are allowed.`);
194
+ }
195
+ const fields = expression.trim().split(/[ \t]+/);
128
196
  if (fields.length !== 5) {
129
197
  throw new Error(`Invalid cron expression "${expression}": expected 5 fields ` +
130
198
  `(minute hour day-of-month month day-of-week), got ${fields.length}.`);
@@ -432,13 +500,26 @@ export function crontabSentinels(profile) {
432
500
  * but fullstackgtm dispatch (no arbitrary shell, ever).
433
501
  */
434
502
  export function renderManagedBlock(profile, entries, cliInvocation) {
503
+ // cliInvocation is spliced raw into the executable line; it is built from
504
+ // process.execPath, the script path, and FSGTM_HOME (cli.ts), so a newline in
505
+ // FSGTM_HOME would inject a crontab line. Validate it like the entry fields —
506
+ // single-quote shell-escaping does NOT defend cron's line parser.
507
+ if (hasControlChar(cliInvocation)) {
508
+ throw new Error("Refusing to render the managed crontab: the resolved CLI invocation (node path, script path, " +
509
+ "or FSGTM_HOME) contains a newline or control character. Check $FSGTM_HOME.");
510
+ }
435
511
  const { open, close } = crontabSentinels(profile);
436
512
  const lines = [
437
513
  open,
438
514
  "# Managed by `fullstackgtm schedule install` — replaced wholesale on re-install; do not edit.",
439
515
  ];
440
516
  for (const entry of entries) {
441
- lines.push(`# ${entry.label} (${entry.id}): ${entry.argv.join(" ")}`);
517
+ // Refuse to render any entry whose interpolated fields carry a control char
518
+ // — the executable line below embeds cron/id raw, so a tampered store could
519
+ // otherwise inject a live crontab line. The comment line is additionally
520
+ // sanitized so a benign-but-messy label can't break it.
521
+ assertRenderableEntry(profile, entry);
522
+ lines.push(sanitizeCrontabComment(`# ${entry.label} (${entry.id}): ${entry.argv.join(" ")}`));
442
523
  lines.push(`${entry.cron} ${cliInvocation} schedule run ${entry.id} --profile ${profile} --trigger cron`);
443
524
  }
444
525
  lines.push(close);
package/docs/api.md CHANGED
@@ -21,7 +21,7 @@ release.
21
21
  - `GtmAuditRule` — `{ id, title, description, category?, evaluate(context) }`; the public extension point.
22
22
  - `GtmRuleContext` — `{ snapshot, policy, index }` with the prebuilt O(n) `GtmSnapshotIndex`.
23
23
  - `auditSnapshot(snapshot, policy?, rules?)` → `PatchPlan`.
24
- - `builtinAuditRules` (11 rules) plus each rule exported individually.
24
+ - `builtinAuditRules` (12 rules) plus each rule exported individually.
25
25
  - **Determinism guarantee**: identical inputs produce identical findings and operations with identical ids (`auditFindingId`, `patchOperationId` are stable hashes of rule + record).
26
26
 
27
27
  ## Patch plans and application
@@ -62,7 +62,9 @@ Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `p
62
62
  `bulk-update`, `dedupe`, `reassign`, `fix`,
63
63
  `market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
64
64
  `axes` / `overlay` / `scale` / `report` / `refresh`),
65
- `enrich` (`append` / `refresh` / `ingest` / `status`), `rules`, `profiles`, `doctor`.
65
+ `enrich` (`append` / `refresh` / `ingest` / `status`),
66
+ `schedule` (`add` / `list` / `remove` / `enable` / `disable` / `run` /
67
+ `install` / `uninstall` / `status`), `rules`, `profiles`, `doctor`.
66
68
  Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
67
69
  (`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
68
70
 
@@ -115,6 +117,30 @@ dependency-free CSV intake; the Apollo client (`createApolloClient`,
115
117
  `pullApolloRecords`, 429-aware with `Retry-After`) is the first `api`-kind
116
118
  source.
117
119
 
120
+ ## Schedule
121
+
122
+ The horizontal scheduler: a declarative schedule-entry store, a
123
+ dependency-free 5-field cron parser, and the read/plan-side `SCHEDULABLE`
124
+ allowlist. `validateSchedulableArgv` enforces the allowlist at `schedule add`
125
+ time and re-checks it at run time (`tokenizeCommand` splits the quoted command
126
+ string — tokenization, never shell). `apply` is schedulable only as
127
+ `apply --plan-id <id>`, with the plan's `approved` status re-checked at every
128
+ firing — an unapproved plan records a `plan_not_approved` no-op run
129
+ (`ScheduleRunRecord.noopReason`) instead of executing.
130
+
131
+ - Entries: `ScheduleEntry` (`ScheduleProvider` is `"local"` for now),
132
+ `scheduleId`, `ScheduleStore` / `createFileScheduleStore`.
133
+ - Run history: `ScheduleRunRecord` (`ScheduleRunTrigger`: `cron` | `manual`),
134
+ `ScheduleRunStore` / `createFileScheduleRunStore`, with `schedulesPath` /
135
+ `scheduleRunsDir` for the profile-scoped file layout.
136
+ - Cron: `parseCron` → `CronExpression`, `cronMatches`, `nextCronFiring`,
137
+ `expectedFirings`, `computeMissedFirings` (status and missed-firing
138
+ visibility — local cron has no catch-up).
139
+ - Local provider: `schedule install` renders enabled entries into a
140
+ sentinel-managed crontab block — `crontabSentinels`, `renderManagedBlock`,
141
+ `replaceManagedBlock`, and `systemCrontabIo` behind the injectable
142
+ `CrontabIo` seam (tests never touch a real crontab).
143
+
118
144
  ## Market map
119
145
 
120
146
  Newer surface (0.16–0.23); shapes are settling toward the 1.0 contract. A live
@@ -78,18 +78,21 @@ values win, **merges cannot be undone**, and a record stops merging after
78
78
  250 cumulative merges. Salesforce merge is SOAP/Apex only (no REST), only
79
79
  Lead/Contact/Account/Case, max 3 records per call.
80
80
 
81
- **The gap:** our three duplicate rules (`duplicate-account-domain`,
82
- `duplicate-contact-email`, `duplicate-open-deal`) detect groups but emit
83
- only merge-review *tasks* detection without remediation.
81
+ **The gap (closed in 0.12):** our three duplicate rules
82
+ (`duplicate-account-domain`, `duplicate-contact-email`,
83
+ `duplicate-open-deal`) used to detect groups but emit only merge-review
84
+ *tasks* — detection without remediation.
84
85
 
85
- **The plan (0.12):** a `merge_records` operation type —
86
+ **Shipped (0.12):** a `merge_records` operation type —
86
87
  `requires_human_survivor_selection` placeholder, survivor heuristics in
87
88
  `suggest` (ordered, evidence-based: most engagements → oldest → most
88
89
  complete, each with a written reason), high risk, approval required, with
89
90
  the irreversibility called out in the plan text. The dry-run plan is the
90
91
  preview every commercial tool charges for; the pre-apply snapshot is the
91
92
  loser-record archive. HubSpot first; Salesforce merge documented as
92
- unsupported until an Apex path justifies itself.
93
+ unsupported until an Apex path justifies itself. 0.23 added `dedupe` as a
94
+ first-class verb over the same operation type (groups → one governed merge
95
+ per group, deterministic survivor).
93
96
 
94
97
  ## D — Delete/Archive: the exit ramp
95
98
 
@@ -131,5 +134,7 @@ Lessons from auditing our own apply path:
131
134
  | 0.11.1 | Fix our own faucet: resolve-first `create:` + plan-scoped dedup, HubSpot association-aware CAS for `link_record`, domain normalization in `duplicate-account-domain`, `create_task` idempotency token |
132
135
  | 0.12 (shipped) | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions capped at low confidence; the three duplicate rules emit governed merges instead of review tasks |
133
136
  | 0.15 (shipped) | `resolve` gate (CLI/lib/MCP, gate exit codes), provenance capture (`hs_object_source*` → `RecordProvenance`) + attribution in duplicate findings, self-stamped creates |
134
- | 0.16 | prevention-posture checks (native duplicate rules active? unique-value properties defined?) · live targeted resolve lookups |
137
+ | 0.16 (shipped the market map instead) | prevention-posture checks (native duplicate rules active? unique-value properties defined?) and live targeted resolve lookups were slated here but did not ship — they remain future work; 0.16 went to the market map layer |
138
+ | 0.23 (shipped) | `dedupe <object> --key <domain\|email\|name>` — the Remediate layer as a first-class verb: duplicate groups by normalized identity key, one governed `merge_records` per group, deterministic survivor (`richest`/`oldest`) |
139
+ | 0.24 (shipped) | schedule layer — recurring Detect: the nightly watch recipe becomes a declared cadence (`schedule add "audit --provider hubspot --save" --cron "0 2 * * *"`); read/plan-side allowlist only, scheduling never auto-approves |
135
140
  | docs | The nightly watch recipe (existing flags, documented as CRM CI) |
@@ -106,6 +106,33 @@ The original thesis: GTM data disagrees across systems.
106
106
  - Docs site with the operating-model registry as browsable reference.
107
107
  - Performance pass: streaming snapshots for very large orgs.
108
108
 
109
+ ## 0.10 → 0.25 — the layers, as shipped
110
+
111
+ The plan above ended at the freeze; what shipped next grew the surface
112
+ outward, one layer per release, each consolidating before the next expanded:
113
+
114
+ - **0.11** — the suggest chain: deterministic placeholder values with
115
+ confidence + reasons, `plans approve --values-from`.
116
+ - **0.12** — governed merge: `merge_records` (HubSpot contacts / companies /
117
+ deals), survivor suggestions capped at low confidence.
118
+ - **0.13–0.14** — call intelligence: `call parse|score|link|plan`, LLM
119
+ extraction behind the bring-your-own-key seam, deterministic baseline,
120
+ provenance-marked insights.
121
+ - **0.15** — the Prevent layer: the `resolve` create gate plus record-source
122
+ provenance and attribution.
123
+ - **0.16–0.22** — the market map: content-addressed captures, classification
124
+ with mechanical span verification, front states and drift, axis discovery,
125
+ overlay directives, scale estimation, the field report.
126
+ - **0.19 / 0.23** — governed write verbs (`bulk-update`, then `dedupe`,
127
+ `reassign`, `fix`) and the enrich layer (Apollo / Clay, fill-blanks-only
128
+ plans).
129
+ - **0.24** — the schedule layer: horizontal cron, read/plan-side allowlist,
130
+ scheduling never auto-approves.
131
+ - **0.25** — agent skill distribution (`npx skills add fullstackgtm/core`).
132
+
133
+ The known-gaps list below predates these layers and has been re-verified
134
+ against the 0.25 surface: still accurate, still open.
135
+
109
136
  ## Known real-portal gaps to close before 1.0
110
137
 
111
138
  Found by exercising the published package as a fresh RevOps user with a real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
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",
@@ -48,7 +48,8 @@ Credentials resolve in order: `--token-env <NAME>` → ambient env
48
48
  In a sandbox prefer the first two. LLM-powered verbs (`call parse`, `call
49
49
  score`, `market classify`) take `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`, or use
50
50
  their deterministic/worksheet fallbacks — the CLI never prompts when
51
- non-interactive.
51
+ non-interactive. `--profile <name>` (or `FULLSTACKGTM_PROFILE`) scopes
52
+ credentials AND stored plans per client org.
52
53
 
53
54
  ## Verb map
54
55
 
@@ -61,9 +62,10 @@ non-interactive.
61
62
  | `fix --rule <id>` | audit one rule → suggest → approve at the confidence bar → apply only with `--yes` |
62
63
  | `call parse\|score\|link\|plan` | Transcripts → evidence-quoted insights, rubric scorecards, deal linking, governed next-step writes |
63
64
  | `enrich append\|refresh\|ingest\|status` | Governed enrichment (Apollo pull / Clay ingest), fill-blanks-only plans |
64
- | `market capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
65
- | `schedule add\|install\|run\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
66
- | `plans list\|approve` / `snapshot` / `rules` / `doctor` | Plan lifecycle, raw snapshots, rule registry, machine state |
65
+ | `market init\|capture\|classify\|worksheet\|observe\|fronts\|axes\|overlay\|scale\|report\|refresh` | Competitive category map; evidence quotes verified verbatim against stored captures |
66
+ | `schedule add\|list\|remove\|enable\|disable\|run\|install\|uninstall\|status` | Horizontal cron; read/plan-side allowlist only — scheduling NEVER auto-approves |
67
+ | `report` | Client-ready audit deliverable (markdown or self-contained HTML) |
68
+ | `plans list\|show\|approve\|reject` / `snapshot` / `rules` / `doctor` | Plan lifecycle, raw snapshots, rule registry, machine state |
67
69
 
68
70
  All write-shaped verbs produce plans; none writes outside approve → apply.
69
71
  Add `--json` for machine-readable output on any command.
package/src/cli.ts CHANGED
@@ -109,6 +109,8 @@ import {
109
109
  parseCron,
110
110
  renderManagedBlock,
111
111
  replaceManagedBlock,
112
+ assertSingleLineLabel,
113
+ hasControlChar,
112
114
  scheduleId,
113
115
  systemCrontabIo,
114
116
  tokenizeCommand,
@@ -1831,6 +1833,7 @@ trigger: manual. status shows next firing and surfaces missed firings
1831
1833
  const label =
1832
1834
  option(rest, "--label") ??
1833
1835
  argv.filter((arg) => !arg.startsWith("--")).slice(0, 2).join("-").replace(/[^\w.-]+/g, "-");
1836
+ assertSingleLineLabel(label);
1834
1837
  const entry: ScheduleEntry = {
1835
1838
  id: scheduleId(label, cron.source, argv, createdAt),
1836
1839
  label,
@@ -2052,12 +2055,26 @@ function scheduleCliInvocation(): string {
2052
2055
  if (!script || !existsSync(script)) {
2053
2056
  throw new Error("Cannot resolve the fullstackgtm entry point for crontab lines (process.argv[1] is missing).");
2054
2057
  }
2058
+ // A newline/control char in any of these flows verbatim into the crontab
2059
+ // executable line; single-quote escaping defends the shell, not cron's line
2060
+ // parser. Refuse early with a clear message (renderManagedBlock re-checks).
2061
+ for (const [name, value] of [
2062
+ ["FSGTM_HOME", process.env.FSGTM_HOME],
2063
+ ["the node executable path", process.execPath],
2064
+ ["the CLI script path", script],
2065
+ ] as const) {
2066
+ if (value && hasControlChar(value)) {
2067
+ throw new Error(`Cannot install schedules: ${name} contains a newline or control character.`);
2068
+ }
2069
+ }
2055
2070
  const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
2056
2071
  const parts = [quote(process.execPath)];
2057
2072
  if (script.endsWith(".ts")) parts.push("--experimental-strip-types");
2058
2073
  parts.push(quote(script));
2059
2074
  const home = process.env.FSGTM_HOME ? `FSGTM_HOME=${quote(process.env.FSGTM_HOME)} ` : "";
2060
- return home + parts.join(" ");
2075
+ // cron treats an unescaped `%` in the command field as a newline/stdin split.
2076
+ // Escape it as `\%` so a stray `%` in a path can't truncate the managed line.
2077
+ return (home + parts.join(" ")).replace(/%/g, "\\%");
2061
2078
  }
2062
2079
 
2063
2080
  /**
@@ -77,8 +77,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
77
77
  throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
78
78
  }
79
79
  if (!response.ok) {
80
- const body = await response.text();
81
- throw new Error(`HubSpot API error ${response.status}: ${body}`);
80
+ // Status line only — HubSpot 4xx bodies echo submitted property values
81
+ // (contact emails, company domains) and the request payload, and these
82
+ // errors are persisted into scheduled-run records. Never interpolate it.
83
+ await response.text().catch(() => undefined);
84
+ throw new Error(`HubSpot API error ${response.status}. Check the token scopes and request.`);
82
85
  }
83
86
  // DELETE and some association writes return 204 with an empty body.
84
87
  const text = await response.text();
@@ -88,8 +88,10 @@ export function createSalesforceConnector(
88
88
  );
89
89
  }
90
90
  if (!response.ok) {
91
- const body = await response.text();
92
- throw new Error(`Salesforce API error ${response.status}: ${body}`);
91
+ // Status line only — the body echoes submitted field values and the
92
+ // request, and these errors are persisted into scheduled-run records.
93
+ await response.text().catch(() => undefined);
94
+ throw new Error(`Salesforce API error ${response.status}. Check the token and request.`);
93
95
  }
94
96
  // Salesforce PATCH returns 204 No Content on success.
95
97
  const text = await response.text();
@@ -46,8 +46,10 @@ export function createStripeConnector(options: StripeConnectorOptions): GtmConne
46
46
  headers: { Authorization: `Bearer ${apiKey}` },
47
47
  });
48
48
  if (!response.ok) {
49
- const body = await response.text();
50
- throw new Error(`Stripe API error ${response.status}: ${body}`);
49
+ // Status line only — the body can echo request details bound to a live
50
+ // billing key, and these errors land in scheduled-run records.
51
+ await response.text().catch(() => undefined);
52
+ throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
51
53
  }
52
54
  return response.json();
53
55
  }
@@ -4,6 +4,7 @@ import {
4
4
  mkdirSync,
5
5
  readdirSync,
6
6
  readFileSync,
7
+ statSync,
7
8
  unlinkSync,
8
9
  writeFileSync,
9
10
  } from "node:fs";
@@ -143,8 +144,31 @@ export function writeSecureFile(path: string, contents: string) {
143
144
  }
144
145
  }
145
146
 
147
+ /**
148
+ * The 0600/0700 guarantee was write-only: a credentials.json inherited at
149
+ * looser permissions (a restored backup, a file created by another tool, a
150
+ * cloned home) was read and trusted regardless of its actual mode. Enforce the
151
+ * mode on read too — re-tighten to 0600 and warn once — so a world-readable
152
+ * credential store can't sit there silently leaking the token to other users.
153
+ */
154
+ function enforceCredentialFileMode(path: string): void {
155
+ try {
156
+ const mode = statSync(path).mode & 0o777;
157
+ if ((mode & 0o077) !== 0) {
158
+ chmodSync(path, 0o600);
159
+ console.error(
160
+ `fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
161
+ "(it was readable or writable by other users).",
162
+ );
163
+ }
164
+ } catch {
165
+ // Missing file or non-POSIX filesystem: nothing to enforce.
166
+ }
167
+ }
168
+
146
169
  function readFile(): CredentialsFile {
147
170
  try {
171
+ enforceCredentialFileMode(credentialsPath());
148
172
  const parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
149
173
  if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
150
174
  return parsed as CredentialsFile;
package/src/enrich.ts CHANGED
@@ -394,6 +394,29 @@ function valueToString(value: unknown): string {
394
394
  return "";
395
395
  }
396
396
 
397
+ /**
398
+ * CSV/formula-injection neutralization for string values destined for a CRM
399
+ * write. Third-party export rows (Clay CSV, webhook JSON) can contain cells
400
+ * like `=cmd|'/c calc'!A1` or `@SUM(...)`; written verbatim to a CRM field they
401
+ * lie dormant until someone exports the CRM to CSV and opens it in a spreadsheet,
402
+ * where the leading `= + - @` (or a leading tab/CR) makes the client execute it.
403
+ * We prefix a single apostrophe — the spreadsheet-standard escape that renders
404
+ * the cell as literal text. Numeric values bypass this (they're written as
405
+ * numbers, not strings), so signed numbers keep full fidelity; a phone number
406
+ * supplied as a string and starting with `+` gains a leading `'`, which the
407
+ * human sees in the approved diff. Applied only at the write path, never to
408
+ * match keys.
409
+ */
410
+ function neutralizeFormulaInjection(value: string): string {
411
+ if (value && /^[=+\-@\t\r]/.test(value)) return `'${value}`;
412
+ return value;
413
+ }
414
+
415
+ /** valueToString for a value that will be written to a CRM field. */
416
+ function writeSafeString(value: unknown): string {
417
+ return neutralizeFormulaInjection(valueToString(value));
418
+ }
419
+
397
420
  // ---------------------------------------------------------------------------
398
421
  // Matching: ordered keys, unique-hit-wins, zero-hits-next-key,
399
422
  // multi-hit → onAmbiguous. Ambiguity is surfaced, never resolved by coin flip.
@@ -708,7 +731,7 @@ export function buildEnrichPlan(options: BuildEnrichPlanOptions): EnrichPlanResu
708
731
  operation: "set_field",
709
732
  field: canonicalField,
710
733
  beforeValue: currentValue ?? null,
711
- afterValue: typeof sourceValue === "number" ? sourceValue : valueToString(sourceValue),
734
+ afterValue: typeof sourceValue === "number" ? sourceValue : writeSafeString(sourceValue),
712
735
  reason:
713
736
  `${source} ${record.objectType} "${describeSourceRecord(record)}" (matched by ` +
714
737
  `${outcome.matchedKey}) reports a changed value for ${canonicalField}.`,
@@ -726,7 +749,7 @@ export function buildEnrichPlan(options: BuildEnrichPlanOptions): EnrichPlanResu
726
749
  if (isEmptyValue(sourceValue)) continue;
727
750
  if (!isEmptyValue(currentValue)) continue;
728
751
  emittedForRecord = true;
729
- const afterValue = typeof sourceValue === "number" ? sourceValue : valueToString(sourceValue);
752
+ const afterValue = typeof sourceValue === "number" ? sourceValue : writeSafeString(sourceValue);
730
753
  operations.push({
731
754
  id: `op_enr_${fnv1a(`${source}:${record.objectType}:${outcome.recordId}:${canonicalField}`)}`,
732
755
  objectType: canonicalObjectType(record.objectType),
@@ -78,9 +78,12 @@ export function createApolloClient(options: ApolloClientOptions): ApolloClient {
78
78
  }
79
79
  if (response.status === 404) return null;
80
80
  if (!response.ok) {
81
- const body = await response.text();
81
+ // Status line only — never interpolate the response body. It can echo
82
+ // the submitted query (contact emails / company domains) or the API key,
83
+ // and these errors are persisted verbatim into scheduled-run records.
84
+ await response.text().catch(() => undefined);
82
85
  const exhausted = response.status === 429 ? ` (rate limited; ${maxRetries} retries exhausted)` : "";
83
- throw new Error(`Apollo API error ${response.status}${exhausted}: ${body}`);
86
+ throw new Error(`Apollo API error ${response.status}${exhausted}. Check the API key and request.`);
84
87
  }
85
88
  const text = await response.text();
86
89
  return text ? (JSON.parse(text) as Record<string, unknown>) : null;