fullstackgtm 0.26.0 → 0.28.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 CHANGED
@@ -5,6 +5,89 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and the project adheres to [Semantic Versioning](https://semver.org/).
6
6
  The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
7
7
 
8
+ ## [0.28.0] — 2026-06-16
9
+
10
+ Connectors, credentials & supply chain — the last of the hardening train.
11
+ Each security change was re-attacked; the keychain and supply-chain gates each
12
+ took two rounds (the re-attack found the stale-plaintext-on-migration gap and
13
+ the orphan-dist gap that round 1 left open).
14
+
15
+ ### Added
16
+
17
+ - **Opt-in OS-keychain credential storage** (`FSGTM_KEYCHAIN=1`). The credential
18
+ blob is stored in the macOS Keychain (`security`) or Linux libsecret
19
+ (`secret-tool`) instead of a 0600 file — no native dependency. Enabling it on
20
+ an existing install migrates `credentials.json` into the keychain and removes
21
+ the plaintext file. Default (unset) is unchanged: the 0600 file. macOS caveat
22
+ (transient argv exposure during the keychain write) documented in SECURITY.md.
23
+
24
+ ### Security
25
+
26
+ - **Broker URL must be https.** `login --via` and the token-mint path refuse a
27
+ cleartext/non-https broker (localhost dev excepted), and the device
28
+ verification URL is only auto-opened when it shares the `--via` origin — so a
29
+ long-lived pairing bearer and minted live-CRM tokens can't go over cleartext
30
+ or to an attacker-redirected URL.
31
+ - **Supply-chain: published dist is provably from source.** `npm run build` now
32
+ cleans first; release rebuilds from source and refuses to publish if the
33
+ committed `dist/` doesn't match (catching a poisoned or *orphaned* dist file),
34
+ and a CI `dist-integrity` job enforces the same on every push — protecting
35
+ `npm install github:` consumers who run the committed dist unbuilt.
36
+ - npx peer resolution from the current directory is now logged (running the MCP
37
+ server in an untrusted directory could otherwise silently load a peer from it).
38
+
39
+ ### Changed
40
+
41
+ - **Salesforce merge is documented as unsupported**, with a connector
42
+ capability matrix in the README — no REST merge exists (SOAP/Apex only), so
43
+ `merge_records` is refused honestly; deduplicate in the UI or archive the
44
+ non-survivors. No silent half-merge, no demo surprise.
45
+
46
+ ## [0.27.0] — 2026-06-16
47
+
48
+ Trust, compliance & transparency — the artifacts a skeptical buyer's security
49
+ and procurement review asks for, plus an exportable audit trail and two
50
+ content-grounding fixes. Security-relevant additions were re-attacked before
51
+ release (the audit-log signing and the transcript gate each took two rounds).
52
+
53
+ ### Added
54
+
55
+ - **`audit-log export` / `audit-log verify`** — a tamper-evident record of every
56
+ apply run, flattened across all plans into a hash chain, with the head
57
+ HMAC-signed by the per-install key. Exports are always signed; `verify`
58
+ recomputes the chain and refuses an edited, reordered, truncated, or
59
+ signature-stripped log (and reports it as unverifiable on a machine without
60
+ the key). The change-management/SIEM artifact the prior audit flagged as
61
+ missing.
62
+ - **`SECURITY.md`** — disclosure address (security@fullstackgtm.com) and the
63
+ full trust model (credential custody, approval gating, approval-integrity
64
+ signing, scheduling, untrusted-input handling, auditability).
65
+ - **`DATA-FLOWS.md`** — exactly what data leaves the machine, to which endpoint,
66
+ for which command, and under whose account; the "CLI is BYO-key, no
67
+ vendor data path, no sub-processors" statement procurement needs; and how to
68
+ run the whole loop with zero third-party calls.
69
+ - **Company-of-record** — `package.json` author and a `NOTICE` file now name
70
+ Full Stack GTM with a contact; LICENSE unchanged (Apache-2.0).
71
+
72
+ ### Security
73
+
74
+ - **Call-transcript insight grounding.** LLM-extracted call insights are now
75
+ mechanically verified: the evidence quote must be a non-trivial verbatim span
76
+ of the transcript, and for `next_step` (the only insight whose text is written
77
+ to the CRM) the written action itself must be grounded in that quote — every
78
+ number/amount must appear in the quote, and the action's distinctive terms
79
+ must overlap it. This closes the prompt-injection path where a transcript
80
+ fabricates a malicious next step accompanied by an innocuous real quote. (This
81
+ is defense-in-depth on a human-approved path; a determined paraphrase-style
82
+ injection still surfaces to the approver as the proposed value.)
83
+
84
+ ### Changed
85
+
86
+ - README now states the design as **deterministic apply, governed suggest** and
87
+ cites the current 1,020-run / five-model benchmark (was a stale 612-run line);
88
+ a CI guard fails if the documented synthetic-scenario count drifts from the
89
+ code.
90
+
8
91
  ## [0.26.0] — 2026-06-15
9
92
 
10
93
  Write-path integrity — the "no write without approval" guarantee now binds to
package/DATA-FLOWS.md ADDED
@@ -0,0 +1,52 @@
1
+ # Data flows & trust boundary
2
+
3
+ A procurement / security review needs to know exactly what data leaves the
4
+ machine, to which endpoint, and under whose account. This is that enumeration
5
+ for the open-source `fullstackgtm` CLI. The short version: **the CLI is
6
+ bring-your-own-key and talks directly to services you already control — there
7
+ is no fullstackgtm-operated server in the data path for the open package.**
8
+
9
+ ## What stays local
10
+
11
+ - CRM snapshots, patch plans, approvals, apply-run records, market captures and
12
+ observations, enrich run state, and the signing/credential stores all live
13
+ under `$FSGTM_HOME` (default `~/.fullstackgtm`), `0600`/`0700`. Nothing is
14
+ uploaded to Full Stack GTM.
15
+ - No telemetry, analytics, or phone-home. The core package has zero runtime
16
+ dependencies; the only network calls are the ones listed below, all to
17
+ endpoints you configure.
18
+
19
+ ## What leaves the machine, by command
20
+
21
+ | Command(s) | Destination | Data sent | Auth |
22
+ |---|---|---|---|
23
+ | `snapshot`, `audit`, `apply`, `resolve`, `bulk-update`, `dedupe`, `reassign`, `fix`, `enrich` (writeback) | **Your CRM** (HubSpot / Salesforce / Stripe API) | Reads: your CRM records. Writes: only approved patch operations. | Your CRM token (env / stored / broker) |
24
+ | `call parse`, `call score`, `market classify`, `market refresh` | **Your LLM provider** (api.anthropic.com or api.openai.com) | The call transcript / captured competitor page text you point at, plus the extraction prompt | Your `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` (BYO) |
25
+ | `enrich append --source apollo`, `enrich refresh` | **Apollo** (api.apollo.io) | The company domain / contact email being enriched | Your `APOLLO_API_KEY` (BYO) |
26
+ | `market capture`, `market refresh` | **Public vendor websites** you list in `market.config.json` | An HTTP GET (no data sent beyond the request); SSRF-guarded to public hosts only | none |
27
+ | `login --via <url>` (optional) | **Your hosted deployment's broker** | A pairing handshake; the broker mints short-lived CRM tokens | broker pairing token |
28
+
29
+ Commands not listed (`plans`, `rules`, `doctor`, `schedule`, `audit-log`,
30
+ `diff`, `merge`, report rendering) make **no network calls**.
31
+
32
+ ## Avoiding third-party data egress
33
+
34
+ - **LLM verbs are optional.** `call parse --deterministic` uses a free,
35
+ offline keyword baseline (no LLM call). `market worksheet` lets an agent or
36
+ human classify without the CLI making an LLM call. A regulated deployment can
37
+ run the full audit → plan → apply loop with **zero third-party calls** —
38
+ CRM-only.
39
+ - **No data is sent for training.** Anthropic, OpenAI, and Apollo are reached
40
+ with your own API keys under your own agreements; their data-handling terms
41
+ (and any DPA you have with them) govern that traffic. Full Stack GTM is not
42
+ in that path and is not a sub-processor for the open-source CLI.
43
+
44
+ ## Sub-processors
45
+
46
+ For the **open-source CLI**: none (BYO-key, direct-to-service). The data
47
+ controllers are you and the providers whose keys you supply.
48
+
49
+ For the **hosted application** (a separate, proprietary product — not this
50
+ package): a sub-processor list and DPA are provided through that product's
51
+ agreement. If you are evaluating the hosted product, request them from
52
+ security@fullstackgtm.com.
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ fullstackgtm
2
+ Copyright 2026 Full Stack GTM
3
+
4
+ This product is developed and maintained by Full Stack GTM (https://fullstackgtm.com).
5
+ Licensed under the Apache License, Version 2.0 (see LICENSE).
package/README.md CHANGED
@@ -127,7 +127,7 @@ fullstackgtm reassign --from 411 --to 902 --except-deal-stage closing --save #
127
127
  fullstackgtm fix --rule missing-deal-owner --provider hubspot --yes # audit one rule → suggest → approve → apply, one command
128
128
  ```
129
129
 
130
- `bulk-update` filters the snapshot (`=`, `!=`, `~` substring, `!~` not-substring, `:empty`/`:notempty`, `|` any-of, relational pseudo-fields like `account.domain` or `openDealStages`) into a dry-run patch plan — and **the full filter is re-verified per record at apply time**, with mid-apply rechecks, so a record that stopped matching between audit and apply is skipped, not clobbered. Equality filters double as preconditions; `--require` adds explicit ones; `--guard` asserts cross-record conditions; `--max-operations` caps blast radius. `--set field=from:<sourceField>` derives values per record; `--create-task <text>` is the third change mode, emitting approval-gated `create_task` operations instead of field writes; `--archive` refuses records whose identity key (account domain, contact email) is shared with another record — that's a duplicate, and duplicates are merged with `dedupe`, not archived around (`--force-archive-duplicates` overrides that refusal explicitly).
130
+ `bulk-update` filters the snapshot (`=`, `!=`, `~` substring, `!~` not-substring, `:empty`/`:notempty`, type-aware comparisons `<` `>` `<=` `>=` where `today` resolves to the policy date — e.g. `closeDate<today` — and date/numeric fields coerce by value form, `|` any-of, relational pseudo-fields like `account.domain` or `openDealStages`) into a dry-run patch plan — and **the full filter is re-verified per record at apply time**, with mid-apply rechecks, so a record that stopped matching between audit and apply is skipped, not clobbered. For date/count hygiene (past close dates, stale deals, missing accounts, duplicates), prefer the rule-backed `fix --rule <id>` — the rule encodes the open-deal + date logic deterministically; use `bulk-update` only when no rule covers the task. Equality filters double as preconditions; `--require` adds explicit ones; `--guard` asserts cross-record conditions; `--max-operations` caps blast radius. `--set field=from:<sourceField>` derives values per record; `--create-task <text>` is the third change mode, emitting approval-gated `create_task` operations instead of field writes; `--archive` refuses records whose identity key (account domain, contact email) is shared with another record — that's a duplicate, and duplicates are merged with `dedupe`, not archived around (`--force-archive-duplicates` overrides that refusal explicitly).
131
131
 
132
132
  `dedupe` finds duplicate groups by normalized identity key and emits one `merge_records` operation per group with a deterministic survivor (`richest` = most populated fields, ties to lowest id; `oldest`). Merges stay irreversible-and-therefore-low-confidence-capped on approval, exactly like merge suggestions from the audit. `reassign` is the ownership-handoff playbook: one plan per object type, extra scoping account-lifted to deals and contacts, and `--except-deal-stage` excludes both deals in that stage and every record whose account has an open deal in it. `fix` is the one-shot composite for a single rule: audit → save → suggest → approve suggestion-backed operations at the confidence bar → with `--yes`, apply and print the stage-by-stage summary; without it, stop after approval and print the apply command.
133
133
 
@@ -219,7 +219,9 @@ fullstackgtm diff --before old.json --after new.json --fail-on-new-findings
219
219
  - `--demo` (with `--seed`) generates a realistic mid-market CRM with injected real-world failure modes — departed owners, unlinked deals, orphan accounts, stale pipeline — so agents and CI can exercise the full snapshot → audit → apply pipeline with zero credentials.
220
220
  - Exit codes: `0` success, `1` error, `2` findings at/above `--fail-on`.
221
221
 
222
- "Built for agents" is measured, not asserted: a 612-run benchmark (17 scenarios × 3 tool-surface arms × 4 trials, deterministic graders over final CRM state, τ-bench-style pass^k) shows the gated CLI surface beating raw CRM-API access on completion-under-policy for every model tested. Full matrix and methodology: [the leaderboard](./evals/crm/leaderboard/RESULTS.md).
222
+ "Built for agents" is measured, not asserted: a 1,020-run benchmark (17 scenarios = 14 synthetic + 3 seeded from an anonymized real portal, × 3 tool-surface arms × 4 trials, across five models from three vendors, deterministic graders over final CRM state, τ-bench-style pass^k) shows the gated CLI surface beating raw CRM-API access on completion-under-policy for every model tested — and the tool-surface effect is monotonic and vendor-independent. Full matrix and methodology: [the leaderboard](./evals/crm/leaderboard/RESULTS.md).
223
+
224
+ The design is **deterministic apply, governed suggest**: the parts that touch your CRM — the audit rules, the plan/apply contract, compare-and-set, the survivor/merge logic — are deterministic and replayable; the parts that read free text (`call parse`/`score`, `market classify`) are LLM-powered but bounded, with every quoted span mechanically verified against the source before it can drive a writeback. Nondeterministic suggestion, deterministic governance.
223
225
 
224
226
  ## Authentication: CLI-first, browser only at the consent moment
225
227
 
@@ -269,6 +271,20 @@ A direct `login hubspot` always wins over a broker pairing, so an operator can o
269
271
 
270
272
  What each provider actually requires before `audit --provider <name>` works on your data.
271
273
 
274
+ ### Connector capabilities
275
+
276
+ Connectors differ in what the provider's API allows — stated up front so nothing surprises you mid-evaluation:
277
+
278
+ | Operation | HubSpot | Salesforce | Stripe |
279
+ | --- | --- | --- | --- |
280
+ | Read / snapshot / audit | ✅ | ✅ | ✅ (read-only) |
281
+ | Field writes (`set_field`, `clear_field`, `link_record`) | ✅ | ✅ | — |
282
+ | `create_task` | ✅ | ✅ | — |
283
+ | `archive_record` | ✅ | ✅ | — |
284
+ | `merge_records` (`dedupe`) | ✅ | ❌ **not supported** | — |
285
+
286
+ **Salesforce merge** has no REST resource — it exists only in the SOAP API / Apex (Lead, Contact, Account, Case; max 3 records). This connector refuses a Salesforce `merge_records` operation honestly rather than half-merging; on Salesforce, deduplicate in the UI (or via SOAP/Apex), or use `bulk-update --archive` for the non-survivors. Native Salesforce merge is tracked future work, not a silent gap.
287
+
272
288
  ### HubSpot: create a private app (~2 minutes, needs super-admin)
273
289
 
274
290
  1. In HubSpot: **Settings → Integrations → Private Apps → Create a private app.**
package/SECURITY.md ADDED
@@ -0,0 +1,82 @@
1
+ # Security Policy
2
+
3
+ fullstackgtm reads and writes live CRM data under the operator's own
4
+ credentials. We take its security posture seriously and design the write path
5
+ to fail closed. This document is the disclosure process and the trust model a
6
+ security reviewer needs.
7
+
8
+ ## Reporting a vulnerability
9
+
10
+ Email **security@fullstackgtm.com** with a description and, ideally, a
11
+ reproduction. Please do not open a public issue for a security report. We aim
12
+ to acknowledge within 3 business days and to ship a fix or mitigation before
13
+ any public disclosure. There is no bounty program yet; credit is given in the
14
+ changelog unless you prefer otherwise.
15
+
16
+ Supported version: the latest published `0.x` release on npm. Fixes land on the
17
+ newest version, not backported (the project is pre-1.0).
18
+
19
+ ## Trust model
20
+
21
+ **Credentials.** API tokens are never accepted as command-line arguments
22
+ (they would leak into the process table and shell history); they come from an
23
+ environment variable or stdin only, and are stored `0600` under a `0700` home
24
+ (`$FSGTM_HOME`, default `~/.fullstackgtm`), re-tightened on read. This is the
25
+ same custody model as the `gcloud`/`aws` CLIs. The hosted broker
26
+ (`login --via`) exists so a team can connect a CRM once, server-side, and hand
27
+ laptops only a revocable pairing token instead of a long-lived super-admin key;
28
+ the broker URL must be https (cleartext is refused except for localhost dev).
29
+
30
+ **OS keychain (opt-in).** Set `FSGTM_KEYCHAIN=1` to store the credential blob
31
+ in the OS secret store instead of a plaintext file — macOS Keychain (via
32
+ `security`) or Linux libsecret (via `secret-tool`); no native dependency. When
33
+ enabled, a pre-existing `credentials.json` is migrated into the keychain and the
34
+ plaintext file is removed, so a cloned home or restored backup finds no token at
35
+ rest. Caveat: on macOS, `security add-generic-password` only accepts the secret
36
+ via an argv flag, so it is briefly visible to a same-user `ps` during the write
37
+ — a transient exposure strictly smaller than a persistent plaintext file, but a
38
+ real one (Linux `secret-tool` reads the secret from stdin, with no such window).
39
+ Backups or clones taken *before* enabling keychain already captured the file;
40
+ rotate those credentials if that is a concern.
41
+
42
+ **Writes are approval-gated.** Reads are safe by default. Every change is a
43
+ typed patch operation in a dry-run plan that a human must approve before
44
+ `apply`. `apply` writes only operations whose ids were explicitly approved,
45
+ refuses operations carrying unresolved placeholder values, and uses
46
+ compare-and-set against the live CRM so a value that drifted since the plan was
47
+ built becomes a conflict, not a clobber. Irreversible operations (merge,
48
+ archive) get a fresh-snapshot drift guard, and archiving a record that still
49
+ shares an identity key with another is refused (it's a duplicate — merge it).
50
+
51
+ **Approval integrity.** At approval time each operation's apply-relevant content
52
+ is HMAC-signed with a per-install key (`$FSGTM_HOME/.plan-signing-key`, `0600`).
53
+ `apply --plan-id` re-verifies; a plan edited after approval — by a synced copy,
54
+ another process, or a compromised dependency — is refused rather than executed.
55
+ The invariant: **what gets written equals what the human signed.** A plan
56
+ approved on one machine cannot be applied on another (the key does not travel).
57
+ Documented boundary: this defends the plan file, not an attacker who already
58
+ holds the signing key (same directory and permissions as the credential store).
59
+
60
+ **Scheduling never auto-approves.** Scheduled (cron) runs are restricted to a
61
+ read/plan-side allowlist plus `apply --plan-id` whose approved status and
62
+ signatures are re-checked at every firing. Arbitrary shell is not schedulable.
63
+
64
+ **Untrusted input.** Competitor pages fetched by `market capture` are guarded
65
+ against SSRF (scheme allowlist; private/loopback/link-local/metadata addresses
66
+ refused; redirects re-validated). LLM-extracted call insights and market
67
+ classifications are mechanically verified verbatim against the source text
68
+ before they can drive a writeback, so a prompt-injected transcript or page
69
+ cannot fabricate a grounded-looking change. CSV/formula-injection in ingested
70
+ data is neutralized before it reaches a write.
71
+
72
+ **Auditability.** `audit-log export` produces a hash-chained, install-signed
73
+ record of every apply run for change-management/SIEM ingestion; `audit-log
74
+ verify` detects any edit or reorder.
75
+
76
+ ## Data flows
77
+
78
+ What leaves the machine, to whom, and for which command is enumerated in
79
+ [DATA-FLOWS.md](./DATA-FLOWS.md). In brief: the core CLI is BYO-key and talks
80
+ directly to your CRM and (only for LLM/enrichment verbs you invoke) to your
81
+ chosen Anthropic/OpenAI/Apollo accounts — there is no fullstackgtm-operated
82
+ data path for the open-source package.
@@ -0,0 +1,58 @@
1
+ import type { PatchPlanRun } from "./types.ts";
2
+ import type { StoredPlan } from "./planStore.ts";
3
+ /**
4
+ * Exportable, tamper-evident audit log.
5
+ *
6
+ * Every apply run is already recorded per-plan in the store, but a compliance /
7
+ * change-management process needs ONE portable artifact it can archive and
8
+ * later prove was not edited. `audit-log export` flattens every run across all
9
+ * plans into a hash-chained sequence: each entry carries the hash of the
10
+ * previous entry, so removing, reordering, or editing any entry breaks the
11
+ * chain at that point and `audit-log verify` reports exactly where. When a
12
+ * per-install signing key exists, the chain head is also HMAC-signed, so the
13
+ * export can be attributed to this installation, not just shown internally
14
+ * consistent.
15
+ *
16
+ * This is a point-in-time attestation of the stored run history; it is not a
17
+ * real-time append-only journal (that is future work). It answers "give me an
18
+ * auditable record of every change this tool applied, that my auditor can
19
+ * verify hasn't been doctored."
20
+ */
21
+ export type AuditLogEntry = {
22
+ seq: number;
23
+ planId: string;
24
+ planTitle: string;
25
+ provider: string;
26
+ startedAt: string;
27
+ finishedAt: string;
28
+ status: PatchPlanRun["status"];
29
+ trigger: string;
30
+ /** operationId → status, the per-operation outcome of this run */
31
+ operations: Array<{
32
+ operationId: string;
33
+ status: string;
34
+ detail?: string;
35
+ }>;
36
+ prevHash: string;
37
+ hash: string;
38
+ };
39
+ export type AuditLogExport = {
40
+ version: 1;
41
+ generatedAt: string;
42
+ entryCount: number;
43
+ chainHead: string;
44
+ /** HMAC of chainHead with the per-install key, or null when no key exists. */
45
+ signature: string | null;
46
+ entries: AuditLogEntry[];
47
+ };
48
+ /** Flatten all runs from the stored plans, oldest first, into chained entries. */
49
+ export declare function buildAuditLog(plans: StoredPlan[], generatedAt: string): AuditLogExport;
50
+ export type AuditLogVerification = {
51
+ ok: boolean;
52
+ /** seq of the first entry whose hash does not verify, or null if the chain holds */
53
+ brokenAt: number | null;
54
+ signatureOk: boolean | null;
55
+ detail: string;
56
+ };
57
+ /** Recompute the chain (and the signature if a key is available). */
58
+ export declare function verifyAuditLog(log: AuditLogExport): AuditLogVerification;
@@ -0,0 +1,112 @@
1
+ import { createHash, createHmac } from "node:crypto";
2
+ import { loadOrCreateSigningKey, loadSigningKey } from "./integrity.js";
3
+ const GENESIS = "0".repeat(64);
4
+ /** The content that the chain hash covers — everything but prevHash/hash. */
5
+ function entryContent(entry) {
6
+ return JSON.stringify([
7
+ entry.seq,
8
+ entry.planId,
9
+ entry.planTitle,
10
+ entry.provider,
11
+ entry.startedAt,
12
+ entry.finishedAt,
13
+ entry.status,
14
+ entry.trigger,
15
+ entry.operations,
16
+ ]);
17
+ }
18
+ function chainHash(prevHash, content) {
19
+ return createHash("sha256").update(prevHash).update("\n").update(content).digest("hex");
20
+ }
21
+ /** Flatten all runs from the stored plans, oldest first, into chained entries. */
22
+ export function buildAuditLog(plans, generatedAt) {
23
+ const runs = [];
24
+ for (const stored of plans) {
25
+ for (const run of stored.runs ?? [])
26
+ runs.push({ stored, run });
27
+ }
28
+ runs.sort((a, b) => a.run.finishedAt.localeCompare(b.run.finishedAt));
29
+ const entries = [];
30
+ let prevHash = GENESIS;
31
+ runs.forEach(({ stored, run }, index) => {
32
+ const base = {
33
+ seq: index,
34
+ planId: run.planId,
35
+ planTitle: stored.plan.title,
36
+ provider: run.provider,
37
+ startedAt: run.startedAt,
38
+ finishedAt: run.finishedAt,
39
+ status: run.status,
40
+ trigger: run.trigger ?? "manual",
41
+ operations: run.results.map((result) => ({
42
+ operationId: result.operationId,
43
+ status: result.status,
44
+ ...(result.detail ? { detail: result.detail } : {}),
45
+ })),
46
+ };
47
+ const hash = chainHash(prevHash, entryContent(base));
48
+ entries.push({ ...base, prevHash, hash });
49
+ prevHash = hash;
50
+ });
51
+ // Always sign — an unsigned export's keyless sha256 chain is self-recomputable
52
+ // (an attacker can edit entries and rebuild the chain from the public genesis),
53
+ // so the per-install HMAC is the only real tamper barrier. Bind the header
54
+ // fields into the signed material so metadata can't be altered either.
55
+ const key = loadOrCreateSigningKey();
56
+ const entryCount = entries.length;
57
+ return {
58
+ version: 1,
59
+ generatedAt,
60
+ entryCount,
61
+ chainHead: prevHash,
62
+ signature: signHead(key, 1, generatedAt, entryCount, prevHash),
63
+ entries,
64
+ };
65
+ }
66
+ function signHead(key, version, generatedAt, entryCount, chainHead) {
67
+ return createHmac("sha256", key).update(JSON.stringify([version, generatedAt, entryCount, chainHead])).digest("hex");
68
+ }
69
+ /** Recompute the chain (and the signature if a key is available). */
70
+ export function verifyAuditLog(log) {
71
+ let prevHash = GENESIS;
72
+ for (const entry of log.entries) {
73
+ if (entry.prevHash !== prevHash) {
74
+ 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).` };
75
+ }
76
+ const expected = chainHash(prevHash, entryContent(entry));
77
+ if (expected !== entry.hash) {
78
+ return { ok: false, brokenAt: entry.seq, signatureOk: null, detail: `Chain breaks at entry ${entry.seq}: its content was edited after export (hash mismatch).` };
79
+ }
80
+ prevHash = entry.hash;
81
+ }
82
+ if (prevHash !== log.chainHead) {
83
+ return { ok: false, brokenAt: log.entries.length, signatureOk: null, detail: "The recorded chainHead does not match the recomputed chain." };
84
+ }
85
+ // The keyless chain alone is self-recomputable, so a missing/stripped signature
86
+ // means the export is forgeable — refuse it. (Current exports are always
87
+ // signed; a null signature is an old/unsigned or a downgraded export.)
88
+ if (!log.signature) {
89
+ return {
90
+ ok: false,
91
+ brokenAt: null,
92
+ signatureOk: false,
93
+ 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.",
94
+ };
95
+ }
96
+ const key = loadSigningKey();
97
+ if (!key) {
98
+ // A third party without the issuing install's key cannot verify attribution.
99
+ // The chain is internally consistent, but that is not proof of authenticity.
100
+ return {
101
+ ok: false,
102
+ brokenAt: null,
103
+ signatureOk: null,
104
+ detail: "Chain is internally consistent, but this machine has no signing key to verify the signature — authenticity is unattributed. Verify on the issuing install.",
105
+ };
106
+ }
107
+ const signatureOk = signHead(key, log.version, log.generatedAt, log.entryCount, prevHash) === log.signature;
108
+ if (!signatureOk) {
109
+ 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." };
110
+ }
111
+ return { ok: true, brokenAt: null, signatureOk: true, detail: `Verified ${log.entries.length} entries; chain intact and signature valid.` };
112
+ }
@@ -33,10 +33,17 @@ export type BulkUpdateOptions = {
33
33
  reason?: string;
34
34
  /** refuse to build plans larger than this (default 500 operations) */
35
35
  maxOperations?: number;
36
+ /**
37
+ * Date the comparison `today` literal resolves to (ISO yyyy-mm-dd). Set from
38
+ * the policy/--today date at the CLI; defaults to the system date. Stored on
39
+ * plan.filter so apply-time filter re-verification resolves `today`
40
+ * identically to plan time.
41
+ */
42
+ today?: string;
36
43
  };
37
44
  type WhereClause = {
38
45
  field: string;
39
- op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty";
46
+ op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty" | "lt" | "gt" | "lte" | "gte";
40
47
  value?: string;
41
48
  raw: string;
42
49
  };
@@ -44,9 +51,14 @@ export declare function parseWhere(expr: string): WhereClause;
44
51
  /** True when `field` is filterable for this object type (relational pseudo-fields included). */
45
52
  export declare function isFilterableField(objectType: BulkUpdateOptions["objectType"], field: string): boolean;
46
53
  export declare function parseGuard(raw: string): PlanGuard;
47
- /** Ids of records matching a filter — used for apply-time filter re-verification. */
48
- export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[]): Set<string>;
54
+ /**
55
+ * Ids of records matching a filter used for apply-time filter
56
+ * re-verification. `today` resolves the comparison `today` literal; apply-time
57
+ * callers pass the value the plan was built with (stored on plan.filter.today)
58
+ * so re-verification uses the SAME today, defaulting to the system date.
59
+ */
60
+ export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[], today?: string): Set<string>;
49
61
  /** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
50
- export declare function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard): string | null;
62
+ export declare function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard, today?: string): string | null;
51
63
  export declare function buildBulkUpdatePlan(snapshot: CanonicalGtmSnapshot, options: BulkUpdateOptions): PatchPlan;
52
64
  export {};