fullstackgtm 0.25.2 → 0.27.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 +98 -0
- package/DATA-FLOWS.md +52 -0
- package/NOTICE +5 -0
- package/README.md +3 -1
- package/SECURITY.md +69 -0
- package/dist/auditLog.d.ts +58 -0
- package/dist/auditLog.js +112 -0
- package/dist/bulkUpdate.js +6 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +102 -0
- package/dist/connector.js +90 -1
- package/dist/dedupe.d.ts +6 -0
- package/dist/dedupe.js +24 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integrity.d.ts +30 -0
- package/dist/integrity.js +128 -0
- package/dist/llm.js +48 -0
- 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.js +4 -0
- package/dist/types.d.ts +16 -0
- package/package.json +6 -3
- package/src/auditLog.ts +173 -0
- package/src/bulkUpdate.ts +6 -1
- package/src/cli.ts +112 -0
- package/src/connector.ts +96 -1
- package/src/dedupe.ts +23 -1
- package/src/index.ts +15 -0
- package/src/integrity.ts +146 -0
- package/src/llm.ts +47 -0
- package/src/marketTaxonomy.ts +288 -0
- package/src/planStore.ts +23 -4
- package/src/schedule.ts +6 -0
- package/src/types.ts +16 -0
package/dist/planStore.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export type StoredPlan = {
|
|
|
11
11
|
status: ApprovalStatus;
|
|
12
12
|
approvedOperationIds: string[];
|
|
13
13
|
valueOverrides: Record<string, unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* HMAC of each approved operation's content at approval time (see
|
|
16
|
+
* integrity.ts). Apply re-verifies these so a post-approval edit to the plan
|
|
17
|
+
* file is caught instead of written. Absent on plans approved before 0.26.0.
|
|
18
|
+
*/
|
|
19
|
+
approvalDigests?: Record<string, string>;
|
|
14
20
|
runs: PatchPlanRun[];
|
|
15
21
|
createdAt: string;
|
|
16
22
|
updatedAt: string;
|
package/dist/planStore.js
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.js";
|
|
4
|
+
import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.js";
|
|
4
5
|
/**
|
|
5
6
|
* Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
|
|
6
7
|
* per plan id. Filesystem-shaped on purpose: greppable, diffable, and any
|
|
@@ -90,11 +91,18 @@ export function createFilePlanStore(directory) {
|
|
|
90
91
|
throw new Error(`Plan ${planId} has no operation ${operationId}.`);
|
|
91
92
|
}
|
|
92
93
|
}
|
|
94
|
+
const approvedOperationIds = Array.from(new Set([...stored.approvedOperationIds, ...operationIds]));
|
|
95
|
+
const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
|
|
96
|
+
// Bind the approval to the operation content so apply can detect a
|
|
97
|
+
// post-approval edit. Recompute over ALL approved ops (a later approve
|
|
98
|
+
// call may add overrides that change an earlier op's resolved value).
|
|
99
|
+
const approvalDigests = computeApprovalDigests(stored.plan.operations, approvedOperationIds, mergedOverrides, loadOrCreateSigningKey());
|
|
93
100
|
return write({
|
|
94
101
|
...stored,
|
|
95
102
|
status: "approved",
|
|
96
|
-
approvedOperationIds
|
|
97
|
-
valueOverrides:
|
|
103
|
+
approvedOperationIds,
|
|
104
|
+
valueOverrides: mergedOverrides,
|
|
105
|
+
approvalDigests,
|
|
98
106
|
});
|
|
99
107
|
},
|
|
100
108
|
async reject(planId) {
|
package/dist/schedule.js
CHANGED
|
@@ -61,6 +61,10 @@ export function validateSchedulableArgv(argv) {
|
|
|
61
61
|
throw new Error("A scheduled apply cannot take --plan/--approve — file-based approval would bypass the " +
|
|
62
62
|
"plan store's approval state. Use `apply --plan-id <id>` and approve via `plans approve`.");
|
|
63
63
|
}
|
|
64
|
+
if (argv.includes("--value")) {
|
|
65
|
+
throw new Error("A scheduled apply cannot take --value — an unattended run must write exactly the values " +
|
|
66
|
+
"signed at approval. Set the value with `plans approve --value <op>=<v>` and re-approve.");
|
|
67
|
+
}
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
70
|
if (!Object.hasOwn(SCHEDULABLE, head)) {
|
package/dist/types.d.ts
CHANGED
|
@@ -239,6 +239,22 @@ export type PatchOperation = {
|
|
|
239
239
|
* member of the group.
|
|
240
240
|
*/
|
|
241
241
|
groupId?: string;
|
|
242
|
+
/**
|
|
243
|
+
* Set only when a human explicitly chose to archive a record that shares an
|
|
244
|
+
* identity key with another (`bulk-update --archive --force-archive-duplicates`).
|
|
245
|
+
* Without it, apply refuses to archive_record a record the live snapshot still
|
|
246
|
+
* sees as a duplicate — archiving a duplicate discards data that merging keeps,
|
|
247
|
+
* and an agent on a dedupe task must not silently substitute archive for merge.
|
|
248
|
+
*/
|
|
249
|
+
forceArchiveDuplicate?: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* For irreversible operations (merge_records, archive_record): the field
|
|
252
|
+
* values of the records that will be destroyed, captured at plan-build time.
|
|
253
|
+
* Merges and archives cannot be undone on any provider, so this is the
|
|
254
|
+
* recovery artifact a human uses to recreate a record by hand if a merge or
|
|
255
|
+
* archive was wrong — the plan file IS the backup.
|
|
256
|
+
*/
|
|
257
|
+
recoverySnapshot?: Record<string, unknown>[];
|
|
242
258
|
};
|
|
243
259
|
/**
|
|
244
260
|
* A patch plan is always a dry-run proposal. Applying a plan never mutates
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"author": "Full Stack GTM",
|
|
6
|
+
"author": "Full Stack GTM <security@fullstackgtm.com> (https://fullstackgtm.com)",
|
|
7
7
|
"homepage": "https://github.com/fullstackgtm/core#readme",
|
|
8
8
|
"bugs": {
|
|
9
9
|
"url": "https://github.com/fullstackgtm/core/issues"
|
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
"INSTALL_FOR_AGENTS.md",
|
|
32
32
|
"llms.txt",
|
|
33
33
|
"skills",
|
|
34
|
-
"LICENSE"
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"NOTICE",
|
|
36
|
+
"SECURITY.md",
|
|
37
|
+
"DATA-FLOWS.md"
|
|
35
38
|
],
|
|
36
39
|
"scripts": {
|
|
37
40
|
"build": "tsc -p tsconfig.build.json",
|
package/src/auditLog.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { loadOrCreateSigningKey, loadSigningKey } from "./integrity.ts";
|
|
3
|
+
import type { PatchPlanRun } from "./types.ts";
|
|
4
|
+
import type { StoredPlan } from "./planStore.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Exportable, tamper-evident audit log.
|
|
8
|
+
*
|
|
9
|
+
* Every apply run is already recorded per-plan in the store, but a compliance /
|
|
10
|
+
* change-management process needs ONE portable artifact it can archive and
|
|
11
|
+
* later prove was not edited. `audit-log export` flattens every run across all
|
|
12
|
+
* plans into a hash-chained sequence: each entry carries the hash of the
|
|
13
|
+
* previous entry, so removing, reordering, or editing any entry breaks the
|
|
14
|
+
* chain at that point and `audit-log verify` reports exactly where. When a
|
|
15
|
+
* per-install signing key exists, the chain head is also HMAC-signed, so the
|
|
16
|
+
* export can be attributed to this installation, not just shown internally
|
|
17
|
+
* consistent.
|
|
18
|
+
*
|
|
19
|
+
* This is a point-in-time attestation of the stored run history; it is not a
|
|
20
|
+
* real-time append-only journal (that is future work). It answers "give me an
|
|
21
|
+
* auditable record of every change this tool applied, that my auditor can
|
|
22
|
+
* verify hasn't been doctored."
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type AuditLogEntry = {
|
|
26
|
+
seq: number;
|
|
27
|
+
planId: string;
|
|
28
|
+
planTitle: string;
|
|
29
|
+
provider: string;
|
|
30
|
+
startedAt: string;
|
|
31
|
+
finishedAt: string;
|
|
32
|
+
status: PatchPlanRun["status"];
|
|
33
|
+
trigger: string;
|
|
34
|
+
/** operationId → status, the per-operation outcome of this run */
|
|
35
|
+
operations: Array<{ operationId: string; status: string; detail?: string }>;
|
|
36
|
+
prevHash: string;
|
|
37
|
+
hash: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type AuditLogExport = {
|
|
41
|
+
version: 1;
|
|
42
|
+
generatedAt: string;
|
|
43
|
+
entryCount: number;
|
|
44
|
+
chainHead: string;
|
|
45
|
+
/** HMAC of chainHead with the per-install key, or null when no key exists. */
|
|
46
|
+
signature: string | null;
|
|
47
|
+
entries: AuditLogEntry[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const GENESIS = "0".repeat(64);
|
|
51
|
+
|
|
52
|
+
/** The content that the chain hash covers — everything but prevHash/hash. */
|
|
53
|
+
function entryContent(entry: Omit<AuditLogEntry, "prevHash" | "hash">): string {
|
|
54
|
+
return JSON.stringify([
|
|
55
|
+
entry.seq,
|
|
56
|
+
entry.planId,
|
|
57
|
+
entry.planTitle,
|
|
58
|
+
entry.provider,
|
|
59
|
+
entry.startedAt,
|
|
60
|
+
entry.finishedAt,
|
|
61
|
+
entry.status,
|
|
62
|
+
entry.trigger,
|
|
63
|
+
entry.operations,
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function chainHash(prevHash: string, content: string): string {
|
|
68
|
+
return createHash("sha256").update(prevHash).update("\n").update(content).digest("hex");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Flatten all runs from the stored plans, oldest first, into chained entries. */
|
|
72
|
+
export function buildAuditLog(plans: StoredPlan[], generatedAt: string): AuditLogExport {
|
|
73
|
+
const runs: Array<{ stored: StoredPlan; run: PatchPlanRun }> = [];
|
|
74
|
+
for (const stored of plans) {
|
|
75
|
+
for (const run of stored.runs ?? []) runs.push({ stored, run });
|
|
76
|
+
}
|
|
77
|
+
runs.sort((a, b) => a.run.finishedAt.localeCompare(b.run.finishedAt));
|
|
78
|
+
|
|
79
|
+
const entries: AuditLogEntry[] = [];
|
|
80
|
+
let prevHash = GENESIS;
|
|
81
|
+
runs.forEach(({ stored, run }, index) => {
|
|
82
|
+
const base = {
|
|
83
|
+
seq: index,
|
|
84
|
+
planId: run.planId,
|
|
85
|
+
planTitle: stored.plan.title,
|
|
86
|
+
provider: run.provider,
|
|
87
|
+
startedAt: run.startedAt,
|
|
88
|
+
finishedAt: run.finishedAt,
|
|
89
|
+
status: run.status,
|
|
90
|
+
trigger: (run as { trigger?: string }).trigger ?? "manual",
|
|
91
|
+
operations: run.results.map((result) => ({
|
|
92
|
+
operationId: result.operationId,
|
|
93
|
+
status: result.status,
|
|
94
|
+
...(result.detail ? { detail: result.detail } : {}),
|
|
95
|
+
})),
|
|
96
|
+
};
|
|
97
|
+
const hash = chainHash(prevHash, entryContent(base));
|
|
98
|
+
entries.push({ ...base, prevHash, hash });
|
|
99
|
+
prevHash = hash;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Always sign — an unsigned export's keyless sha256 chain is self-recomputable
|
|
103
|
+
// (an attacker can edit entries and rebuild the chain from the public genesis),
|
|
104
|
+
// so the per-install HMAC is the only real tamper barrier. Bind the header
|
|
105
|
+
// fields into the signed material so metadata can't be altered either.
|
|
106
|
+
const key = loadOrCreateSigningKey();
|
|
107
|
+
const entryCount = entries.length;
|
|
108
|
+
return {
|
|
109
|
+
version: 1,
|
|
110
|
+
generatedAt,
|
|
111
|
+
entryCount,
|
|
112
|
+
chainHead: prevHash,
|
|
113
|
+
signature: signHead(key, 1, generatedAt, entryCount, prevHash),
|
|
114
|
+
entries,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function signHead(key: Buffer, version: number, generatedAt: string, entryCount: number, chainHead: string): string {
|
|
119
|
+
return createHmac("sha256", key).update(JSON.stringify([version, generatedAt, entryCount, chainHead])).digest("hex");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type AuditLogVerification = {
|
|
123
|
+
ok: boolean;
|
|
124
|
+
/** seq of the first entry whose hash does not verify, or null if the chain holds */
|
|
125
|
+
brokenAt: number | null;
|
|
126
|
+
signatureOk: boolean | null; // null = no signature present / no key to check
|
|
127
|
+
detail: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Recompute the chain (and the signature if a key is available). */
|
|
131
|
+
export function verifyAuditLog(log: AuditLogExport): AuditLogVerification {
|
|
132
|
+
let prevHash = GENESIS;
|
|
133
|
+
for (const entry of log.entries) {
|
|
134
|
+
if (entry.prevHash !== prevHash) {
|
|
135
|
+
return { ok: false, brokenAt: entry.seq, signatureOk: null, detail: `Chain breaks at entry ${entry.seq}: prevHash does not match the previous entry's hash (an entry was removed, reordered, or edited).` };
|
|
136
|
+
}
|
|
137
|
+
const expected = chainHash(prevHash, entryContent(entry));
|
|
138
|
+
if (expected !== entry.hash) {
|
|
139
|
+
return { ok: false, brokenAt: entry.seq, signatureOk: null, detail: `Chain breaks at entry ${entry.seq}: its content was edited after export (hash mismatch).` };
|
|
140
|
+
}
|
|
141
|
+
prevHash = entry.hash;
|
|
142
|
+
}
|
|
143
|
+
if (prevHash !== log.chainHead) {
|
|
144
|
+
return { ok: false, brokenAt: log.entries.length, signatureOk: null, detail: "The recorded chainHead does not match the recomputed chain." };
|
|
145
|
+
}
|
|
146
|
+
// The keyless chain alone is self-recomputable, so a missing/stripped signature
|
|
147
|
+
// means the export is forgeable — refuse it. (Current exports are always
|
|
148
|
+
// signed; a null signature is an old/unsigned or a downgraded export.)
|
|
149
|
+
if (!log.signature) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
brokenAt: null,
|
|
153
|
+
signatureOk: false,
|
|
154
|
+
detail: "Unsigned export: the hash chain alone is self-recomputable, so this log cannot be trusted (the signature is absent or was stripped). Re-export on the issuing install.",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const key = loadSigningKey();
|
|
158
|
+
if (!key) {
|
|
159
|
+
// A third party without the issuing install's key cannot verify attribution.
|
|
160
|
+
// The chain is internally consistent, but that is not proof of authenticity.
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
brokenAt: null,
|
|
164
|
+
signatureOk: null,
|
|
165
|
+
detail: "Chain is internally consistent, but this machine has no signing key to verify the signature — authenticity is unattributed. Verify on the issuing install.",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const signatureOk = signHead(key, log.version, log.generatedAt, log.entryCount, prevHash) === log.signature;
|
|
169
|
+
if (!signatureOk) {
|
|
170
|
+
return { ok: false, brokenAt: null, signatureOk: false, detail: "Signature does not match this installation's key — the log was exported elsewhere, or its entries/metadata were altered after signing." };
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, brokenAt: null, signatureOk: true, detail: `Verified ${log.entries.length} entries; chain intact and signature valid.` };
|
|
173
|
+
}
|
package/src/bulkUpdate.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `account.ownerId`, `account.contactCount`; accounts get `contactCount`
|
|
28
28
|
* and `openDealCount`.
|
|
29
29
|
*/
|
|
30
|
+
import { recoverableFields } from "./dedupe.ts";
|
|
30
31
|
import { normalizeDomain } from "./merge.ts";
|
|
31
32
|
import { stableHash } from "./rules.ts";
|
|
32
33
|
import type {
|
|
@@ -399,7 +400,11 @@ export function buildBulkUpdatePlan(
|
|
|
399
400
|
beforeValue: null,
|
|
400
401
|
afterValue: null,
|
|
401
402
|
riskLevel: "high",
|
|
402
|
-
|
|
403
|
+
// Carry the human's explicit force decision to the apply-time guard, and
|
|
404
|
+
// snapshot the record so it can be recreated if the archive was wrong.
|
|
405
|
+
...(options.forceArchiveDuplicates ? { forceArchiveDuplicate: true } : {}),
|
|
406
|
+
recoverySnapshot: [recoverableFields(record)],
|
|
407
|
+
rollback: "Archived records can be restored from the provider's recycle bin within its retention window; recoverySnapshot also retains the field values.",
|
|
403
408
|
});
|
|
404
409
|
continue;
|
|
405
410
|
}
|
package/src/cli.ts
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
import { generateDemoSnapshot } from "./demo.ts";
|
|
35
35
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
36
36
|
import { mergeSnapshots } from "./merge.ts";
|
|
37
|
+
import { verifyApprovalDigests } from "./integrity.ts";
|
|
38
|
+
import { buildAuditLog, verifyAuditLog } from "./auditLog.ts";
|
|
37
39
|
import { createFilePlanStore } from "./planStore.ts";
|
|
38
40
|
import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
39
41
|
import { builtinAuditRules } from "./rules.ts";
|
|
@@ -60,6 +62,7 @@ import {
|
|
|
60
62
|
type CallDocument,
|
|
61
63
|
} from "./marketOverlay.ts";
|
|
62
64
|
import { computeScaleIndex, scaleReportToText } from "./marketScale.ts";
|
|
65
|
+
import { suggestMarketConfig } from "./marketTaxonomy.ts";
|
|
63
66
|
import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
|
|
64
67
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
65
68
|
import {
|
|
@@ -252,6 +255,7 @@ Usage:
|
|
|
252
255
|
fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
|
|
253
256
|
fullstackgtm apply --plan-id <id> --provider <name>
|
|
254
257
|
fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
|
|
258
|
+
fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
|
|
255
259
|
fullstackgtm rules [--json]
|
|
256
260
|
fullstackgtm profiles [--json] list credential profiles
|
|
257
261
|
fullstackgtm doctor [--json] check install, credentials, and next step
|
|
@@ -976,6 +980,8 @@ async function marketCommand(args: string[]) {
|
|
|
976
980
|
if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
977
981
|
console.log(`Usage:
|
|
978
982
|
market init --category <name> [--out <path>] write a starter market.config.json
|
|
983
|
+
market init --category <name> --auto --vendor <url> [--vendor <url>...] [--anchor <url>] [--max-claims n]
|
|
984
|
+
LLM-propose vendors + claim taxonomy from seed pages (needs an API key)
|
|
979
985
|
market capture [--config <path>] [--run <label>]
|
|
980
986
|
market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
|
|
981
987
|
market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
@@ -1025,6 +1031,31 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
1025
1031
|
if (!category) throw new Error("market init requires --category <name>");
|
|
1026
1032
|
const outPath = resolve(process.cwd(), option(rest, "--out") ?? "market.config.json");
|
|
1027
1033
|
if (existsSync(outPath)) throw new Error(`${outPath} already exists — refusing to overwrite`);
|
|
1034
|
+
|
|
1035
|
+
if (rest.includes("--auto")) {
|
|
1036
|
+
const vendorUrls = repeatedOption(rest, "--vendor");
|
|
1037
|
+
if (vendorUrls.length === 0) {
|
|
1038
|
+
throw new Error("market init --auto requires at least one --vendor <url> (the competitor homepages to seed from)");
|
|
1039
|
+
}
|
|
1040
|
+
const anchorUrl = option(rest, "--anchor");
|
|
1041
|
+
const credential = await requireLlmCredential("market classify");
|
|
1042
|
+
console.error(`Capturing ${vendorUrls.length} seed page(s) and proposing a claim taxonomy with ${credential.provider}…`);
|
|
1043
|
+
const { config, unreadableVendorIds, model } = await suggestMarketConfig({
|
|
1044
|
+
category,
|
|
1045
|
+
vendors: vendorUrls.map((url) => ({ url, anchor: anchorUrl ? url === anchorUrl : false })),
|
|
1046
|
+
llm: { ...credential, model: option(rest, "--model") ?? undefined },
|
|
1047
|
+
maxClaims: numericOption(rest, "--max-claims"),
|
|
1048
|
+
});
|
|
1049
|
+
writeFileSync(outPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
1050
|
+
if (unreadableVendorIds.length > 0) {
|
|
1051
|
+
console.error(`Note: no readable text for ${unreadableVendorIds.join(", ")} — excluded from taxonomy grounding.`);
|
|
1052
|
+
}
|
|
1053
|
+
console.log(
|
|
1054
|
+
`Wrote ${outPath}: ${config.vendors.length} vendors, ${config.claims.length} proposed claims (${model}). Review it, then: fullstackgtm market refresh`,
|
|
1055
|
+
);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1028
1059
|
writeFileSync(outPath, `${JSON.stringify(starterMarketConfig(category), null, 2)}\n`);
|
|
1029
1060
|
console.log(`Wrote ${outPath}. Fill in vendors and claims, then: fullstackgtm market capture`);
|
|
1030
1061
|
return;
|
|
@@ -2529,6 +2560,50 @@ function readSuggestionValues(path: string, minConfidence: string, includeCreate
|
|
|
2529
2560
|
return { overrides, skipped };
|
|
2530
2561
|
}
|
|
2531
2562
|
|
|
2563
|
+
async function auditLogCommand(args: string[]) {
|
|
2564
|
+
const [sub, ...rest] = args;
|
|
2565
|
+
if (!sub || sub === "--help" || sub === "-h" || (sub !== "export" && sub !== "verify")) {
|
|
2566
|
+
console.log(`Usage:
|
|
2567
|
+
audit-log export [--out <path>] [--json] hash-chained, signed record of every apply run
|
|
2568
|
+
audit-log verify [--in <path>] re-check an exported log's chain and signature
|
|
2569
|
+
|
|
2570
|
+
export flattens every apply run across all stored plans (this profile) into a
|
|
2571
|
+
tamper-evident chain — each entry carries the prior entry's hash, and the chain
|
|
2572
|
+
head is HMAC-signed with this install's key — so a change-management process can
|
|
2573
|
+
archive one file and later prove it was not edited. verify recomputes the chain
|
|
2574
|
+
and (if the signing key is present) the signature.`);
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
if (sub === "export") {
|
|
2579
|
+
const plans = await createFilePlanStore().list();
|
|
2580
|
+
const log = buildAuditLog(plans, new Date().toISOString());
|
|
2581
|
+
const payload = `${JSON.stringify(log, null, 2)}\n`;
|
|
2582
|
+
const outPath = option(rest, "--out");
|
|
2583
|
+
if (outPath) {
|
|
2584
|
+
writeFileSync(resolve(process.cwd(), outPath), payload);
|
|
2585
|
+
console.log(`Wrote ${outPath}: ${log.entryCount} run(s), chain head ${log.chainHead.slice(0, 12)}${log.signature ? " (signed)" : " (unsigned — no signing key on this install)"}.`);
|
|
2586
|
+
} else if (rest.includes("--json")) {
|
|
2587
|
+
console.log(payload);
|
|
2588
|
+
} else {
|
|
2589
|
+
console.log(`${log.entryCount} apply run(s); chain head ${log.chainHead.slice(0, 12)}${log.signature ? ", signed" : ", unsigned"}. Pass --out <path> to archive, or --json to print.`);
|
|
2590
|
+
}
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// verify
|
|
2595
|
+
const inPath = option(rest, "--in");
|
|
2596
|
+
if (!inPath) throw new Error("audit-log verify requires --in <exported-log.json>");
|
|
2597
|
+
const log = JSON.parse(readFileSync(resolve(process.cwd(), inPath), "utf8")) as Parameters<typeof verifyAuditLog>[0];
|
|
2598
|
+
const result = verifyAuditLog(log);
|
|
2599
|
+
if (rest.includes("--json")) {
|
|
2600
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2601
|
+
} else {
|
|
2602
|
+
console.log(result.ok ? `OK — ${result.detail}` : `TAMPERED — ${result.detail}`);
|
|
2603
|
+
}
|
|
2604
|
+
if (!result.ok) process.exitCode = 2;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2532
2607
|
async function apply(args: string[]) {
|
|
2533
2608
|
const provider = option(args, "--provider");
|
|
2534
2609
|
if (!provider) throw new Error("apply requires --provider <name>");
|
|
@@ -2552,7 +2627,40 @@ async function apply(args: string[]) {
|
|
|
2552
2627
|
}
|
|
2553
2628
|
plan = stored.plan;
|
|
2554
2629
|
approvedOperationIds = stored.approvedOperationIds;
|
|
2630
|
+
// Downgrade guard: an approved plan with no signatures is either pre-0.26
|
|
2631
|
+
// (re-approve to gain them) or had its approvalDigests stripped to skip the
|
|
2632
|
+
// integrity check. Either way, refuse rather than fall back to trusting the
|
|
2633
|
+
// file. (A plan with zero approved operations has nothing to apply anyway.)
|
|
2634
|
+
if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
|
|
2635
|
+
throw new Error(
|
|
2636
|
+
`Refusing to apply plan ${planId}: it was approved without integrity signatures ` +
|
|
2637
|
+
"(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
|
|
2638
|
+
`\`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
// Integrity gate: the plan file is re-read from disk, so verify each approved
|
|
2642
|
+
// operation still matches what was signed at approval. Verify against the
|
|
2643
|
+
// EFFECTIVE overrides (stored ∪ apply-time --value): the invariant is "what
|
|
2644
|
+
// gets written must equal what was signed", so an apply-time --value that
|
|
2645
|
+
// changes a value the human did not approve is treated as tamper, not a live
|
|
2646
|
+
// override. A mismatch means the plan/overrides were edited after approval —
|
|
2647
|
+
// refuse the whole apply rather than write an unapproved value.
|
|
2555
2648
|
valueOverrides = { ...stored.valueOverrides, ...parseValueOverrides(args) };
|
|
2649
|
+
const verification = verifyApprovalDigests(
|
|
2650
|
+
stored.plan.operations,
|
|
2651
|
+
stored.approvedOperationIds,
|
|
2652
|
+
valueOverrides,
|
|
2653
|
+
stored.approvalDigests,
|
|
2654
|
+
);
|
|
2655
|
+
if (!verification.ok) {
|
|
2656
|
+
const detail =
|
|
2657
|
+
verification.reason === "no_key"
|
|
2658
|
+
? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
|
|
2659
|
+
: `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
|
|
2660
|
+
"If you changed a value at apply time, set it at approval instead (`plans approve --value <op>=<v>`) and re-approve; " +
|
|
2661
|
+
"otherwise the plan was edited after approval — review and re-approve.";
|
|
2662
|
+
throw new Error(`Refusing to apply plan ${planId}: ${detail}`);
|
|
2663
|
+
}
|
|
2556
2664
|
} else {
|
|
2557
2665
|
const approve = option(args, "--approve");
|
|
2558
2666
|
if (!approve) {
|
|
@@ -3354,6 +3462,10 @@ export async function runCli(argv: string[]) {
|
|
|
3354
3462
|
await plansCommand(args);
|
|
3355
3463
|
return;
|
|
3356
3464
|
}
|
|
3465
|
+
if (command === "audit-log") {
|
|
3466
|
+
await auditLogCommand(args);
|
|
3467
|
+
return;
|
|
3468
|
+
}
|
|
3357
3469
|
if (command === "apply") {
|
|
3358
3470
|
await apply(args);
|
|
3359
3471
|
return;
|
package/src/connector.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { dedupeKey } from "./dedupe.ts";
|
|
1
2
|
import { requiresHumanInput } from "./rules.ts";
|
|
2
3
|
import type {
|
|
4
|
+
CanonicalGtmSnapshot,
|
|
3
5
|
GtmConnector,
|
|
4
6
|
PatchOperation,
|
|
5
7
|
PatchOperationResult,
|
|
@@ -8,6 +10,75 @@ import type {
|
|
|
8
10
|
PatchPlanRunStatus,
|
|
9
11
|
} from "./types.ts";
|
|
10
12
|
|
|
13
|
+
const IRREVERSIBLE_OPERATIONS = new Set(["merge_records", "archive_record"]);
|
|
14
|
+
const IDENTITY_KEY_BY_TYPE: Partial<Record<string, "domain" | "email">> = {
|
|
15
|
+
account: "domain",
|
|
16
|
+
contact: "email",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** snapshot collection for an object type */
|
|
20
|
+
function collectionFor(objectType: string): "accounts" | "contacts" | "deals" | null {
|
|
21
|
+
if (objectType === "account") return "accounts";
|
|
22
|
+
if (objectType === "contact") return "contacts";
|
|
23
|
+
if (objectType === "deal") return "deals";
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Drift/safety check for the two IRREVERSIBLE operations against a fresh
|
|
29
|
+
* snapshot. Returns a conflict detail string, or null if the op is safe to
|
|
30
|
+
* apply. These operations get NO field compare-and-set (there is no single
|
|
31
|
+
* field to compare), so this snapshot check is their only guard.
|
|
32
|
+
*/
|
|
33
|
+
function checkIrreversibleOp(operation: PatchOperation, snapshot: CanonicalGtmSnapshot): string | null {
|
|
34
|
+
const collection = collectionFor(operation.objectType);
|
|
35
|
+
if (!collection) return null;
|
|
36
|
+
const records = snapshot[collection] as Array<Record<string, unknown>>;
|
|
37
|
+
const byId = (id: string) => records.find((record) => String(record.id) === id);
|
|
38
|
+
|
|
39
|
+
if (operation.operation === "archive_record") {
|
|
40
|
+
if (!byId(operation.objectId)) {
|
|
41
|
+
return `Record ${operation.objectType}/${operation.objectId} no longer exists (already archived or merged). Re-plan against current data.`;
|
|
42
|
+
}
|
|
43
|
+
// Archiving a duplicate discards data a merge would keep — refuse unless the
|
|
44
|
+
// human explicitly forced it. This catches every archive_record path (agent,
|
|
45
|
+
// hand-edited plan, audit), not just `bulk-update --archive`.
|
|
46
|
+
if (!operation.forceArchiveDuplicate) {
|
|
47
|
+
const keyName = IDENTITY_KEY_BY_TYPE[operation.objectType];
|
|
48
|
+
if (keyName) {
|
|
49
|
+
const target = byId(operation.objectId)!;
|
|
50
|
+
const key = dedupeKey(target, keyName);
|
|
51
|
+
if (key) {
|
|
52
|
+
const sharers = records.filter(
|
|
53
|
+
(record) => String(record.id) !== operation.objectId && dedupeKey(record, keyName) === key,
|
|
54
|
+
);
|
|
55
|
+
if (sharers.length > 0) {
|
|
56
|
+
return (
|
|
57
|
+
`Refusing to archive ${operation.objectType}/${operation.objectId}: it shares ${keyName} "${key}" with ` +
|
|
58
|
+
`${sharers.length} other record(s) — that's a duplicate, and archiving discards its data where merging keeps it. ` +
|
|
59
|
+
`Merge with \`fullstackgtm dedupe ${operation.objectType} --key ${keyName}\` instead, or rebuild the op with --force-archive-duplicates.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (operation.operation === "merge_records") {
|
|
69
|
+
if (!byId(operation.objectId)) {
|
|
70
|
+
return `Merge survivor ${operation.objectType}/${operation.objectId} no longer exists (archived or merged away since the plan was built). Re-plan — merges are irreversible.`;
|
|
71
|
+
}
|
|
72
|
+
const groupIds = Array.isArray(operation.beforeValue) ? (operation.beforeValue as unknown[]).map(String) : [];
|
|
73
|
+
const losersStillPresent = groupIds.filter((id) => id !== operation.objectId && byId(id));
|
|
74
|
+
if (groupIds.length > 0 && losersStillPresent.length === 0) {
|
|
75
|
+
return `Every record to merge into ${operation.objectType}/${operation.objectId} is already gone (merge already applied?). Nothing to do — re-plan if duplicates remain.`;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
11
82
|
export type ApplyPatchPlanOptions = {
|
|
12
83
|
/**
|
|
13
84
|
* Explicit allow-list of operation ids the human approved. Operations not
|
|
@@ -79,10 +150,20 @@ export async function applyPatchPlan(
|
|
|
79
150
|
// closed — but it can be shrunk: re-run the snapshot checks after the
|
|
80
151
|
// first write and every `recheckEvery` writes, conflicting out any
|
|
81
152
|
// operation whose record went stale mid-run.
|
|
153
|
+
// Irreversible ops (merge/archive) need a fresh snapshot too — it is their
|
|
154
|
+
// only drift/safety guard (no field to compare-and-set). Respect a caller's
|
|
155
|
+
// explicit checkConflicts:false opt-out (a stub/known-stale snapshot).
|
|
156
|
+
const hasIrreversibleApproved =
|
|
157
|
+
checkConflicts &&
|
|
158
|
+
plan.operations.some(
|
|
159
|
+
(operation) => approved.has(operation.id) && IRREVERSIBLE_OPERATIONS.has(operation.operation),
|
|
160
|
+
);
|
|
82
161
|
const needsSnapshot =
|
|
83
|
-
((plan.guards && plan.guards.length > 0) || plan.filter) &&
|
|
162
|
+
((plan.guards && plan.guards.length > 0) || plan.filter || hasIrreversibleApproved) &&
|
|
163
|
+
connector.fetchSnapshot;
|
|
84
164
|
const recheckEvery = Math.max(1, options.recheckEvery ?? 25);
|
|
85
165
|
const staleIds = new Set<string>();
|
|
166
|
+
const irreversibleStale = new Map<string, string>();
|
|
86
167
|
let guardFailure: string | null = null;
|
|
87
168
|
const refreshSnapshotChecks = async (): Promise<void> => {
|
|
88
169
|
if (!needsSnapshot) return;
|
|
@@ -95,6 +176,14 @@ export async function applyPatchPlan(
|
|
|
95
176
|
if (!stillEligible.has(operation.objectId)) staleIds.add(operation.objectId);
|
|
96
177
|
}
|
|
97
178
|
}
|
|
179
|
+
irreversibleStale.clear();
|
|
180
|
+
if (checkConflicts) {
|
|
181
|
+
for (const operation of plan.operations) {
|
|
182
|
+
if (!approved.has(operation.id) || !IRREVERSIBLE_OPERATIONS.has(operation.operation)) continue;
|
|
183
|
+
const detail = checkIrreversibleOp(operation, liveSnapshot);
|
|
184
|
+
if (detail) irreversibleStale.set(operation.id, detail);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
98
187
|
for (const guard of plan.guards ?? []) {
|
|
99
188
|
const failure = evaluateGuard(liveSnapshot, guard);
|
|
100
189
|
if (failure) {
|
|
@@ -232,6 +321,12 @@ export async function applyPatchPlan(
|
|
|
232
321
|
if (operation.groupId) poisonedGroups.add(operation.groupId);
|
|
233
322
|
continue;
|
|
234
323
|
}
|
|
324
|
+
const irreversibleConflict = irreversibleStale.get(operation.id);
|
|
325
|
+
if (irreversibleConflict) {
|
|
326
|
+
results.push({ operationId: operation.id, status: "conflict", detail: irreversibleConflict });
|
|
327
|
+
if (operation.groupId) poisonedGroups.add(operation.groupId);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
235
330
|
if (operation.groupId && poisonedGroups.has(operation.groupId)) {
|
|
236
331
|
results.push({
|
|
237
332
|
operationId: operation.id,
|
package/src/dedupe.ts
CHANGED
|
@@ -65,6 +65,21 @@ function populatedDataFields(record: Record<string, unknown>): number {
|
|
|
65
65
|
).length;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The subset of a record worth keeping as a merge-recovery artifact: its id (to
|
|
70
|
+
* reference) plus every populated data field, dropping bulky/plumbing fields
|
|
71
|
+
* (raw, identities, provenance) that aren't needed to recreate it by hand.
|
|
72
|
+
*/
|
|
73
|
+
export function recoverableFields(record: Record<string, unknown>): Record<string, unknown> {
|
|
74
|
+
const out: Record<string, unknown> = { id: String(record.id) };
|
|
75
|
+
for (const [field, value] of Object.entries(record)) {
|
|
76
|
+
if (NON_DATA_FIELDS.has(field)) continue;
|
|
77
|
+
if (value === undefined || value === null || value === "") continue;
|
|
78
|
+
out[field] = value;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
68
83
|
/** True when id `a` sorts before id `b` — numeric when both ids are numeric. */
|
|
69
84
|
function idBefore(a: string, b: string): boolean {
|
|
70
85
|
const numericA = Number(a);
|
|
@@ -137,6 +152,12 @@ export function buildDedupePlan(
|
|
|
137
152
|
const groupIds = members
|
|
138
153
|
.map((member) => String(member.id))
|
|
139
154
|
.sort((a, b) => (idBefore(a, b) ? -1 : 1));
|
|
155
|
+
// Recovery artifact: the records that will be merged away (everyone but the
|
|
156
|
+
// survivor), captured with their field values so a human can recreate one by
|
|
157
|
+
// hand if the merge was wrong. Merges are irreversible — the plan is the backup.
|
|
158
|
+
const recoverySnapshot = members
|
|
159
|
+
.filter((member) => String(member.id) !== String(survivor.id))
|
|
160
|
+
.map((member) => recoverableFields(member));
|
|
140
161
|
const survivorName =
|
|
141
162
|
typeof survivor.name === "string" && survivor.name
|
|
142
163
|
? survivor.name
|
|
@@ -162,8 +183,9 @@ export function buildDedupePlan(
|
|
|
162
183
|
approvalRequired: true,
|
|
163
184
|
sourceRuleOrPolicy: "dedupe",
|
|
164
185
|
groupId: `grp_${options.objectType}_${String(survivor.id)}`,
|
|
186
|
+
recoverySnapshot,
|
|
165
187
|
rollback:
|
|
166
|
-
"IRREVERSIBLE: provider merges cannot be unmerged.
|
|
188
|
+
"IRREVERSIBLE: provider merges cannot be unmerged. recoverySnapshot on this operation retains every merged-away record's field values; recreate a record manually from it if a merge was wrong.",
|
|
167
189
|
});
|
|
168
190
|
}
|
|
169
191
|
|
package/src/index.ts
CHANGED
|
@@ -115,6 +115,21 @@ export {
|
|
|
115
115
|
type MergeSuggestion,
|
|
116
116
|
} from "./merge.ts";
|
|
117
117
|
export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
|
|
118
|
+
export {
|
|
119
|
+
computeApprovalDigests,
|
|
120
|
+
loadOrCreateSigningKey,
|
|
121
|
+
loadSigningKey,
|
|
122
|
+
signApproval,
|
|
123
|
+
verifyApprovalDigests,
|
|
124
|
+
type ApprovalVerification,
|
|
125
|
+
} from "./integrity.ts";
|
|
126
|
+
export {
|
|
127
|
+
buildAuditLog,
|
|
128
|
+
verifyAuditLog,
|
|
129
|
+
type AuditLogEntry,
|
|
130
|
+
type AuditLogExport,
|
|
131
|
+
type AuditLogVerification,
|
|
132
|
+
} from "./auditLog.ts";
|
|
118
133
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
119
134
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
120
135
|
export {
|