fullstackgtm 0.25.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/dist/bulkUpdate.js +6 -1
- package/dist/cli.js +67 -2
- package/dist/connector.js +90 -1
- package/dist/connectors/hubspot.js +5 -2
- package/dist/connectors/salesforce.js +4 -2
- package/dist/connectors/stripe.js +4 -2
- package/dist/credentials.js +22 -1
- package/dist/dedupe.d.ts +6 -0
- package/dist/dedupe.js +24 -1
- package/dist/enrich.js +24 -2
- package/dist/enrichApollo.js +5 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/integrity.d.ts +30 -0
- package/dist/integrity.js +128 -0
- package/dist/market.d.ts +1 -0
- package/dist/market.js +144 -8
- package/dist/marketReport.d.ts +9 -0
- package/dist/marketReport.js +29 -4
- package/dist/marketTaxonomy.d.ts +41 -0
- package/dist/marketTaxonomy.js +193 -0
- package/dist/planStore.d.ts +6 -0
- package/dist/planStore.js +10 -2
- package/dist/schedule.d.ts +17 -0
- package/dist/schedule.js +87 -2
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +6 -1
- package/src/cli.ts +80 -1
- package/src/connector.ts +96 -1
- package/src/connectors/hubspot.ts +5 -2
- package/src/connectors/salesforce.ts +4 -2
- package/src/connectors/stripe.ts +4 -2
- package/src/credentials.ts +24 -0
- package/src/dedupe.ts +23 -1
- package/src/enrich.ts +25 -2
- package/src/enrichApollo.ts +5 -2
- package/src/index.ts +8 -0
- package/src/integrity.ts +146 -0
- package/src/market.ts +129 -8
- package/src/marketReport.ts +30 -4
- package/src/marketTaxonomy.ts +288 -0
- package/src/planStore.ts +23 -4
- package/src/schedule.ts +98 -2
- package/src/types.ts +16 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MODELS,
|
|
3
|
+
forcedToolCall,
|
|
4
|
+
type LlmCallOptions,
|
|
5
|
+
} from "./llm.ts";
|
|
6
|
+
import {
|
|
7
|
+
captureMarket,
|
|
8
|
+
type FetchPage,
|
|
9
|
+
loadCaptureTexts,
|
|
10
|
+
type MarketClaim,
|
|
11
|
+
type MarketConfig,
|
|
12
|
+
type MarketVendor,
|
|
13
|
+
} from "./market.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cold-start taxonomy bootstrap. `market init` writes a stub for a human
|
|
17
|
+
* analyst to fill in; the self-serve hosted map has no analyst in the loop, so
|
|
18
|
+
* this proposes the claim taxonomy automatically from the seed vendors' own
|
|
19
|
+
* pages.
|
|
20
|
+
*
|
|
21
|
+
* Posture matches the rest of the market layer: the LLM is a *proposal* layer
|
|
22
|
+
* grounded in captured evidence (it only sees text we actually fetched), and
|
|
23
|
+
* everything downstream — capture, classify with verbatim-span verification,
|
|
24
|
+
* front states, the report — stays deterministic over the stored observations.
|
|
25
|
+
* The taxonomy it emits is a normal `market.config.json` a human can still edit.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type SeedVendor = {
|
|
29
|
+
url: string;
|
|
30
|
+
/** Display name; derived from the host when omitted. */
|
|
31
|
+
name?: string;
|
|
32
|
+
/** Marks the user's own company as the anchor vendor. */
|
|
33
|
+
anchor?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type SuggestTaxonomyOptions = {
|
|
37
|
+
category: string;
|
|
38
|
+
vendors: SeedVendor[];
|
|
39
|
+
llm: LlmCallOptions;
|
|
40
|
+
/** Upper bound on proposed claims, to keep classification bounded. */
|
|
41
|
+
maxClaims?: number;
|
|
42
|
+
/** Per-vendor captured-text budget fed to the proposer (chars). */
|
|
43
|
+
perVendorChars?: number;
|
|
44
|
+
/** Test injectables. */
|
|
45
|
+
fetchPage?: FetchPage;
|
|
46
|
+
capturesDir?: string;
|
|
47
|
+
now?: () => Date;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type SuggestTaxonomyResult = {
|
|
51
|
+
config: MarketConfig;
|
|
52
|
+
/** Vendors whose homepage capture was empty/failed (excluded from grounding). */
|
|
53
|
+
unreadableVendorIds: string[];
|
|
54
|
+
model: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const DEFAULT_MAX_CLAIMS = 16;
|
|
58
|
+
const DEFAULT_PER_VENDOR_CHARS = 6_000;
|
|
59
|
+
|
|
60
|
+
/** Stable, human-readable id from a string (claim capability or host). */
|
|
61
|
+
function slugify(value: string, maxWords = 6): string {
|
|
62
|
+
const slug = value
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
65
|
+
.replace(/^-+|-+$/g, "")
|
|
66
|
+
.split("-")
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.slice(0, maxWords)
|
|
69
|
+
.join("-");
|
|
70
|
+
return slug || "item";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Second-level domain as a vendor id seed: https://www.stripe.com/ -> stripe. */
|
|
74
|
+
function vendorIdFromUrl(url: string): string {
|
|
75
|
+
let host: string;
|
|
76
|
+
try {
|
|
77
|
+
host = new URL(url).hostname;
|
|
78
|
+
} catch {
|
|
79
|
+
return slugify(url);
|
|
80
|
+
}
|
|
81
|
+
const labels = host.replace(/^www\./, "").split(".");
|
|
82
|
+
const sld = labels.length >= 2 ? labels[labels.length - 2] : labels[0];
|
|
83
|
+
return slugify(sld || host);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Disambiguate repeated ids by suffixing -2, -3, … */
|
|
87
|
+
function uniqueId(base: string, taken: Set<string>): string {
|
|
88
|
+
if (!taken.has(base)) {
|
|
89
|
+
taken.add(base);
|
|
90
|
+
return base;
|
|
91
|
+
}
|
|
92
|
+
for (let n = 2; ; n += 1) {
|
|
93
|
+
const candidate = `${base}-${n}`;
|
|
94
|
+
if (!taken.has(candidate)) {
|
|
95
|
+
taken.add(candidate);
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function provisionalVendors(seeds: SeedVendor[]): MarketVendor[] {
|
|
102
|
+
const taken = new Set<string>();
|
|
103
|
+
return seeds.map((seed) => {
|
|
104
|
+
const id = uniqueId(vendorIdFromUrl(seed.url), taken);
|
|
105
|
+
const host = (() => {
|
|
106
|
+
try {
|
|
107
|
+
return new URL(seed.url).hostname.replace(/^www\./, "");
|
|
108
|
+
} catch {
|
|
109
|
+
return seed.url;
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
name: seed.name?.trim() || host,
|
|
115
|
+
urls: { home: seed.url, pricing: null, product: [] },
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type ProposedClaim = {
|
|
121
|
+
capability: string;
|
|
122
|
+
icp: string;
|
|
123
|
+
pricingStructure: string;
|
|
124
|
+
definition: string;
|
|
125
|
+
terms?: string[];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
type ProposedVendor = { seedUrl: string; name?: string; pricingUrl?: string | null };
|
|
129
|
+
|
|
130
|
+
const TAXONOMY_SCHEMA = {
|
|
131
|
+
type: "object",
|
|
132
|
+
required: ["claims"],
|
|
133
|
+
properties: {
|
|
134
|
+
surfaceRule: {
|
|
135
|
+
type: "string",
|
|
136
|
+
description:
|
|
137
|
+
"One sentence stating how a reader judges LOUD vs QUIET vs ABSENT for this category (e.g. hero/top-nav = LOUD, deeper pages = QUIET, nowhere = ABSENT).",
|
|
138
|
+
},
|
|
139
|
+
claims: {
|
|
140
|
+
type: "array",
|
|
141
|
+
description:
|
|
142
|
+
"The distinct capability positions vendors in this category compete on. 8-16 of them. Only include claims you can actually see evidence for on the supplied pages.",
|
|
143
|
+
items: {
|
|
144
|
+
type: "object",
|
|
145
|
+
required: ["capability", "icp", "pricingStructure", "definition"],
|
|
146
|
+
properties: {
|
|
147
|
+
capability: {
|
|
148
|
+
type: "string",
|
|
149
|
+
description: "What is being claimed, precise enough to judge loud/quiet/absent. Max ~10 words.",
|
|
150
|
+
},
|
|
151
|
+
icp: { type: "string", description: "Which buyer/ICP this claim cell addresses (category vocabulary)." },
|
|
152
|
+
pricingStructure: {
|
|
153
|
+
type: "string",
|
|
154
|
+
description: "Which pricing structure the claim implies (e.g. per-seat, usage-based, flat, free-tier).",
|
|
155
|
+
},
|
|
156
|
+
definition: {
|
|
157
|
+
type: "string",
|
|
158
|
+
description:
|
|
159
|
+
"Operational definition a human (or classifier) uses to score any vendor's page LOUD/QUIET/ABSENT on this claim.",
|
|
160
|
+
},
|
|
161
|
+
terms: {
|
|
162
|
+
type: "array",
|
|
163
|
+
items: { type: "string" },
|
|
164
|
+
description: "Exact buyer phrasings for this claim, for deterministic mention matching. 2-5 terms.",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
vendors: {
|
|
170
|
+
type: "array",
|
|
171
|
+
description: "Optional refinements: a clean display name per seed URL, and a pricing-page URL if one is clearly linked.",
|
|
172
|
+
items: {
|
|
173
|
+
type: "object",
|
|
174
|
+
required: ["seedUrl"],
|
|
175
|
+
properties: {
|
|
176
|
+
seedUrl: { type: "string" },
|
|
177
|
+
name: { type: "string" },
|
|
178
|
+
pricingUrl: { type: ["string", "null"] },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
} as const;
|
|
184
|
+
|
|
185
|
+
function buildDossier(
|
|
186
|
+
vendors: MarketVendor[],
|
|
187
|
+
capture: ReturnType<typeof loadCaptureTexts>,
|
|
188
|
+
perVendorChars: number,
|
|
189
|
+
): { dossier: string; unreadable: string[] } {
|
|
190
|
+
const { entries, textByHash } = capture;
|
|
191
|
+
const unreadable: string[] = [];
|
|
192
|
+
const blocks: string[] = [];
|
|
193
|
+
for (const vendor of vendors) {
|
|
194
|
+
const hash = entries.find((e) => e.vendorId === vendor.id && e.captureHash)?.captureHash ?? null;
|
|
195
|
+
const text = hash ? textByHash.get(hash) ?? "" : "";
|
|
196
|
+
if (!text.trim()) {
|
|
197
|
+
unreadable.push(vendor.id);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
blocks.push(`### ${vendor.name} (${vendor.urls.home})\n${text.slice(0, perVendorChars)}`);
|
|
201
|
+
}
|
|
202
|
+
return { dossier: blocks.join("\n\n"), unreadable };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const INSTRUCTIONS = `You are seeding a competitive "market map" for a category. A market map breaks the category into CLAIMS — the distinct capability positions vendors compete on — so each (vendor x claim) cell can later be scored LOUD / QUIET / ABSENT from that vendor's pages.
|
|
206
|
+
|
|
207
|
+
Propose the claim taxonomy for this category from the competitor homepages below. Rules:
|
|
208
|
+
- Ground every claim in what is actually visible on the supplied pages. Do not invent positions no vendor mentions.
|
|
209
|
+
- Each claim is a cell: a precise capability, the ICP it targets, and the pricing structure it implies.
|
|
210
|
+
- Write each definition so a reader could judge ANY vendor's page LOUD/QUIET/ABSENT against it.
|
|
211
|
+
- Aim for the 8-16 claims that genuinely differentiate vendors. Prefer specific, contested positions over generic table stakes.
|
|
212
|
+
- Provide 2-5 verbatim buyer terms per claim for later mention matching.
|
|
213
|
+
- Optionally return a cleaned display name and a pricing-page URL per seed vendor when evident.`;
|
|
214
|
+
|
|
215
|
+
export async function suggestMarketConfig(options: SuggestTaxonomyOptions): Promise<SuggestTaxonomyResult> {
|
|
216
|
+
const { category } = options;
|
|
217
|
+
if (options.vendors.length === 0) throw new Error("suggestMarketConfig requires at least one seed vendor");
|
|
218
|
+
const maxClaims = options.maxClaims ?? DEFAULT_MAX_CLAIMS;
|
|
219
|
+
const perVendorChars = options.perVendorChars ?? DEFAULT_PER_VENDOR_CHARS;
|
|
220
|
+
const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
|
|
221
|
+
|
|
222
|
+
const vendors = provisionalVendors(options.vendors);
|
|
223
|
+
const anchorSeed = options.vendors.find((seed) => seed.anchor);
|
|
224
|
+
const anchorId = anchorSeed ? vendors[options.vendors.indexOf(anchorSeed)]?.id : undefined;
|
|
225
|
+
|
|
226
|
+
// Capture the seed homepages so the proposer only sees text we actually
|
|
227
|
+
// fetched (the SSRF guard in captureMarket applies to these user-supplied URLs).
|
|
228
|
+
await captureMarket(
|
|
229
|
+
{ category, vendors, claims: [] },
|
|
230
|
+
{ dir: options.capturesDir, runLabel: "bootstrap", fetchPage: options.fetchPage, now: options.now },
|
|
231
|
+
);
|
|
232
|
+
const capture = loadCaptureTexts(category, options.capturesDir);
|
|
233
|
+
const { dossier, unreadable } = buildDossier(vendors, capture, perVendorChars);
|
|
234
|
+
if (!dossier.trim()) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`market init --auto: none of the ${vendors.length} seed pages returned readable text — check the URLs are public homepages.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const prompt = `${INSTRUCTIONS}\n\nCategory: ${category}\n\nCompetitor homepages:\n${dossier}`;
|
|
241
|
+
const result = (await forcedToolCall(prompt, "propose_market_taxonomy", TAXONOMY_SCHEMA, model, options.llm)) as {
|
|
242
|
+
surfaceRule?: string;
|
|
243
|
+
claims?: ProposedClaim[];
|
|
244
|
+
vendors?: ProposedVendor[];
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const takenClaimIds = new Set<string>();
|
|
248
|
+
const claims: MarketClaim[] = (result.claims ?? [])
|
|
249
|
+
.filter((claim) => claim?.capability && claim?.definition)
|
|
250
|
+
.slice(0, maxClaims)
|
|
251
|
+
.map((claim) => ({
|
|
252
|
+
id: uniqueId(slugify(claim.capability), takenClaimIds),
|
|
253
|
+
capability: claim.capability.trim(),
|
|
254
|
+
icp: (claim.icp ?? "").trim() || "general",
|
|
255
|
+
pricingStructure: (claim.pricingStructure ?? "").trim() || "unspecified",
|
|
256
|
+
definition: claim.definition.trim(),
|
|
257
|
+
...(claim.terms?.length ? { terms: claim.terms.map((t) => t.trim()).filter(Boolean) } : {}),
|
|
258
|
+
}));
|
|
259
|
+
|
|
260
|
+
if (claims.length === 0) {
|
|
261
|
+
throw new Error("market init --auto: the model proposed no usable claims — try again or seed the taxonomy by hand.");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Apply optional vendor refinements (display name + pricing URL), matched by seed URL.
|
|
265
|
+
const refinementByUrl = new Map((result.vendors ?? []).map((v) => [v.seedUrl, v]));
|
|
266
|
+
const refinedVendors: MarketVendor[] = vendors.map((vendor) => {
|
|
267
|
+
const refinement = refinementByUrl.get(vendor.urls.home);
|
|
268
|
+
const pricing =
|
|
269
|
+
refinement?.pricingUrl && /^https?:\/\//i.test(refinement.pricingUrl) ? refinement.pricingUrl : vendor.urls.pricing;
|
|
270
|
+
return {
|
|
271
|
+
...vendor,
|
|
272
|
+
name: refinement?.name?.trim() || vendor.name,
|
|
273
|
+
urls: { ...vendor.urls, pricing },
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const config: MarketConfig = {
|
|
278
|
+
category,
|
|
279
|
+
...(anchorId ? { anchorVendor: anchorId } : {}),
|
|
280
|
+
vendors: refinedVendors,
|
|
281
|
+
claims,
|
|
282
|
+
surfaceRule:
|
|
283
|
+
result.surfaceRule?.trim() ||
|
|
284
|
+
"LOUD = hero copy OR top-level-nav named product with a dedicated page; QUIET = present on any indexed page below that; ABSENT = nowhere observed; UNOBSERVABLE = capture empty/failed — never score ABSENT from a failed capture.",
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
return { config, unreadableVendorIds: unreadable, model };
|
|
288
|
+
}
|
package/src/planStore.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chmodSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
4
|
+
import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.ts";
|
|
4
5
|
import type { ApprovalStatus, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -16,6 +17,12 @@ export type StoredPlan = {
|
|
|
16
17
|
status: ApprovalStatus;
|
|
17
18
|
approvedOperationIds: string[];
|
|
18
19
|
valueOverrides: Record<string, unknown>;
|
|
20
|
+
/**
|
|
21
|
+
* HMAC of each approved operation's content at approval time (see
|
|
22
|
+
* integrity.ts). Apply re-verifies these so a post-approval edit to the plan
|
|
23
|
+
* file is caught instead of written. Absent on plans approved before 0.26.0.
|
|
24
|
+
*/
|
|
25
|
+
approvalDigests?: Record<string, string>;
|
|
19
26
|
runs: PatchPlanRun[];
|
|
20
27
|
createdAt: string;
|
|
21
28
|
updatedAt: string;
|
|
@@ -125,13 +132,25 @@ export function createFilePlanStore(directory?: string): PlanStore {
|
|
|
125
132
|
throw new Error(`Plan ${planId} has no operation ${operationId}.`);
|
|
126
133
|
}
|
|
127
134
|
}
|
|
135
|
+
const approvedOperationIds = Array.from(
|
|
136
|
+
new Set([...stored.approvedOperationIds, ...operationIds]),
|
|
137
|
+
);
|
|
138
|
+
const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
|
|
139
|
+
// Bind the approval to the operation content so apply can detect a
|
|
140
|
+
// post-approval edit. Recompute over ALL approved ops (a later approve
|
|
141
|
+
// call may add overrides that change an earlier op's resolved value).
|
|
142
|
+
const approvalDigests = computeApprovalDigests(
|
|
143
|
+
stored.plan.operations,
|
|
144
|
+
approvedOperationIds,
|
|
145
|
+
mergedOverrides,
|
|
146
|
+
loadOrCreateSigningKey(),
|
|
147
|
+
);
|
|
128
148
|
return write({
|
|
129
149
|
...stored,
|
|
130
150
|
status: "approved",
|
|
131
|
-
approvedOperationIds
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
valueOverrides: { ...stored.valueOverrides, ...valueOverrides },
|
|
151
|
+
approvedOperationIds,
|
|
152
|
+
valueOverrides: mergedOverrides,
|
|
153
|
+
approvalDigests,
|
|
135
154
|
});
|
|
136
155
|
},
|
|
137
156
|
|
package/src/schedule.ts
CHANGED
|
@@ -124,6 +124,12 @@ export function validateSchedulableArgv(argv: string[]): void {
|
|
|
124
124
|
"plan store's approval state. Use `apply --plan-id <id>` and approve via `plans approve`.",
|
|
125
125
|
);
|
|
126
126
|
}
|
|
127
|
+
if (argv.includes("--value")) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"A scheduled apply cannot take --value — an unattended run must write exactly the values " +
|
|
130
|
+
"signed at approval. Set the value with `plans approve --value <op>=<v>` and re-approve.",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
127
133
|
return;
|
|
128
134
|
}
|
|
129
135
|
if (!Object.hasOwn(SCHEDULABLE, head)) {
|
|
@@ -145,6 +151,75 @@ export function validateSchedulableArgv(argv: string[]): void {
|
|
|
145
151
|
}
|
|
146
152
|
}
|
|
147
153
|
|
|
154
|
+
/**
|
|
155
|
+
* A schedule label is free text the operator chooses, but it is later
|
|
156
|
+
* interpolated into a crontab comment line by `renderManagedBlock`. A newline
|
|
157
|
+
* (or carriage return) would break out of the comment and inject an arbitrary
|
|
158
|
+
* crontab entry on `schedule install`. Reject control characters at the entry
|
|
159
|
+
* point so a label can never carry a second line; `renderManagedBlock` also
|
|
160
|
+
* strips them defensively in case a hand-edited schedules.json slips one past.
|
|
161
|
+
*/
|
|
162
|
+
export function assertSingleLineLabel(label: string): void {
|
|
163
|
+
if (hasControlChar(label)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
"A schedule --label cannot contain newlines or control characters " +
|
|
166
|
+
"(they would inject lines into the managed crontab block). Use a plain single-line name.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* True if the string contains any line-breaking or control character. Covers
|
|
173
|
+
* C0 controls + DEL, plus the Unicode separators a non-cron parser might honor
|
|
174
|
+
* (NEL U+0085, LS U+2028, PS U+2029, VT U+000B, FF U+000C) — defense-in-depth
|
|
175
|
+
* for the future modal/aws scaffold renderers whose target formats may treat
|
|
176
|
+
* those as line breaks.
|
|
177
|
+
*/
|
|
178
|
+
export function hasControlChar(value: string): boolean {
|
|
179
|
+
for (let i = 0; i < value.length; i++) {
|
|
180
|
+
const code = value.charCodeAt(i);
|
|
181
|
+
if (code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029) return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Collapse any control/separator character to a space — last-resort guard at render time. */
|
|
187
|
+
function sanitizeCrontabComment(value: string): string {
|
|
188
|
+
let out = "";
|
|
189
|
+
for (const ch of value) {
|
|
190
|
+
const code = ch.charCodeAt(0);
|
|
191
|
+
out += code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029 ? " " : ch;
|
|
192
|
+
}
|
|
193
|
+
return out.replace(/ {2,}/g, " ").trim();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Validate every field of an entry that `renderManagedBlock` interpolates into
|
|
198
|
+
* the crontab — not just the label. The EXECUTABLE line embeds `cron` and `id`
|
|
199
|
+
* raw, and `schedule install` renders entries straight from schedules.json, so
|
|
200
|
+
* a hand-edited (or otherwise tampered) entry with a newline in cron/id/profile
|
|
201
|
+
* would inject a live crontab line. Refuse to render a tampered entry rather
|
|
202
|
+
* than emit it. (Well-formed entries never trip this: cron is parser-validated,
|
|
203
|
+
* id is an fnv1a hex hash, label is guarded at add-time.)
|
|
204
|
+
*/
|
|
205
|
+
function assertRenderableEntry(profile: string, entry: ScheduleEntry): void {
|
|
206
|
+
const fields: Array<[string, string]> = [
|
|
207
|
+
["profile", profile],
|
|
208
|
+
["cron", entry.cron],
|
|
209
|
+
["id", entry.id],
|
|
210
|
+
["label", entry.label],
|
|
211
|
+
...entry.argv.map((token, i) => [`argv[${i}]`, token] as [string, string]),
|
|
212
|
+
];
|
|
213
|
+
for (const [name, value] of fields) {
|
|
214
|
+
if (hasControlChar(value)) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Refusing to render schedule entry ${entry.id}: its ${name} contains a newline or control character. ` +
|
|
217
|
+
"The schedules.json store has been tampered with or corrupted — repair it before installing.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
148
223
|
/**
|
|
149
224
|
* Split a `schedule add "<command>"` string into argv, honoring single and
|
|
150
225
|
* double quotes (no escapes, no expansion — this is tokenization, not shell).
|
|
@@ -206,7 +281,13 @@ const CRON_FIELD_SPECS = [
|
|
|
206
281
|
] as const;
|
|
207
282
|
|
|
208
283
|
export function parseCron(expression: string): CronExpression {
|
|
209
|
-
|
|
284
|
+
// Reject non-ASCII whitespace and control chars: JS \s splits on U+00A0,
|
|
285
|
+
// U+3000, etc., but Vixie cron's field separator is only space/tab. A source
|
|
286
|
+
// carrying them would parse here yet be misparsed or rejected by `crontab -`.
|
|
287
|
+
if (hasControlChar(expression) || /[^\x20-\x7e]/.test(expression)) {
|
|
288
|
+
throw new Error(`Invalid cron expression "${expression}": only ASCII characters, space, and tab are allowed.`);
|
|
289
|
+
}
|
|
290
|
+
const fields = expression.trim().split(/[ \t]+/);
|
|
210
291
|
if (fields.length !== 5) {
|
|
211
292
|
throw new Error(
|
|
212
293
|
`Invalid cron expression "${expression}": expected 5 fields ` +
|
|
@@ -559,13 +640,28 @@ export function renderManagedBlock(
|
|
|
559
640
|
entries: ScheduleEntry[],
|
|
560
641
|
cliInvocation: string,
|
|
561
642
|
): string {
|
|
643
|
+
// cliInvocation is spliced raw into the executable line; it is built from
|
|
644
|
+
// process.execPath, the script path, and FSGTM_HOME (cli.ts), so a newline in
|
|
645
|
+
// FSGTM_HOME would inject a crontab line. Validate it like the entry fields —
|
|
646
|
+
// single-quote shell-escaping does NOT defend cron's line parser.
|
|
647
|
+
if (hasControlChar(cliInvocation)) {
|
|
648
|
+
throw new Error(
|
|
649
|
+
"Refusing to render the managed crontab: the resolved CLI invocation (node path, script path, " +
|
|
650
|
+
"or FSGTM_HOME) contains a newline or control character. Check $FSGTM_HOME.",
|
|
651
|
+
);
|
|
652
|
+
}
|
|
562
653
|
const { open, close } = crontabSentinels(profile);
|
|
563
654
|
const lines = [
|
|
564
655
|
open,
|
|
565
656
|
"# Managed by `fullstackgtm schedule install` — replaced wholesale on re-install; do not edit.",
|
|
566
657
|
];
|
|
567
658
|
for (const entry of entries) {
|
|
568
|
-
|
|
659
|
+
// Refuse to render any entry whose interpolated fields carry a control char
|
|
660
|
+
// — the executable line below embeds cron/id raw, so a tampered store could
|
|
661
|
+
// otherwise inject a live crontab line. The comment line is additionally
|
|
662
|
+
// sanitized so a benign-but-messy label can't break it.
|
|
663
|
+
assertRenderableEntry(profile, entry);
|
|
664
|
+
lines.push(sanitizeCrontabComment(`# ${entry.label} (${entry.id}): ${entry.argv.join(" ")}`));
|
|
569
665
|
lines.push(`${entry.cron} ${cliInvocation} schedule run ${entry.id} --profile ${profile} --trigger cron`);
|
|
570
666
|
}
|
|
571
667
|
lines.push(close);
|
package/src/types.ts
CHANGED
|
@@ -303,6 +303,22 @@ export type PatchOperation = {
|
|
|
303
303
|
* member of the group.
|
|
304
304
|
*/
|
|
305
305
|
groupId?: string;
|
|
306
|
+
/**
|
|
307
|
+
* Set only when a human explicitly chose to archive a record that shares an
|
|
308
|
+
* identity key with another (`bulk-update --archive --force-archive-duplicates`).
|
|
309
|
+
* Without it, apply refuses to archive_record a record the live snapshot still
|
|
310
|
+
* sees as a duplicate — archiving a duplicate discards data that merging keeps,
|
|
311
|
+
* and an agent on a dedupe task must not silently substitute archive for merge.
|
|
312
|
+
*/
|
|
313
|
+
forceArchiveDuplicate?: boolean;
|
|
314
|
+
/**
|
|
315
|
+
* For irreversible operations (merge_records, archive_record): the field
|
|
316
|
+
* values of the records that will be destroyed, captured at plan-build time.
|
|
317
|
+
* Merges and archives cannot be undone on any provider, so this is the
|
|
318
|
+
* recovery artifact a human uses to recreate a record by hand if a merge or
|
|
319
|
+
* archive was wrong — the plan file IS the backup.
|
|
320
|
+
*/
|
|
321
|
+
recoverySnapshot?: Record<string, unknown>[];
|
|
306
322
|
};
|
|
307
323
|
|
|
308
324
|
/**
|