fullstackgtm 0.26.0 → 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 +45 -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/cli.d.ts +1 -1
- package/dist/cli.js +52 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/llm.js +48 -0
- package/package.json +6 -3
- package/src/auditLog.ts +173 -0
- package/src/cli.ts +50 -0
- package/src/index.ts +7 -0
- package/src/llm.ts +47 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,51 @@ 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.27.0] — 2026-06-16
|
|
9
|
+
|
|
10
|
+
Trust, compliance & transparency — the artifacts a skeptical buyer's security
|
|
11
|
+
and procurement review asks for, plus an exportable audit trail and two
|
|
12
|
+
content-grounding fixes. Security-relevant additions were re-attacked before
|
|
13
|
+
release (the audit-log signing and the transcript gate each took two rounds).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`audit-log export` / `audit-log verify`** — a tamper-evident record of every
|
|
18
|
+
apply run, flattened across all plans into a hash chain, with the head
|
|
19
|
+
HMAC-signed by the per-install key. Exports are always signed; `verify`
|
|
20
|
+
recomputes the chain and refuses an edited, reordered, truncated, or
|
|
21
|
+
signature-stripped log (and reports it as unverifiable on a machine without
|
|
22
|
+
the key). The change-management/SIEM artifact the prior audit flagged as
|
|
23
|
+
missing.
|
|
24
|
+
- **`SECURITY.md`** — disclosure address (security@fullstackgtm.com) and the
|
|
25
|
+
full trust model (credential custody, approval gating, approval-integrity
|
|
26
|
+
signing, scheduling, untrusted-input handling, auditability).
|
|
27
|
+
- **`DATA-FLOWS.md`** — exactly what data leaves the machine, to which endpoint,
|
|
28
|
+
for which command, and under whose account; the "CLI is BYO-key, no
|
|
29
|
+
vendor data path, no sub-processors" statement procurement needs; and how to
|
|
30
|
+
run the whole loop with zero third-party calls.
|
|
31
|
+
- **Company-of-record** — `package.json` author and a `NOTICE` file now name
|
|
32
|
+
Full Stack GTM with a contact; LICENSE unchanged (Apache-2.0).
|
|
33
|
+
|
|
34
|
+
### Security
|
|
35
|
+
|
|
36
|
+
- **Call-transcript insight grounding.** LLM-extracted call insights are now
|
|
37
|
+
mechanically verified: the evidence quote must be a non-trivial verbatim span
|
|
38
|
+
of the transcript, and for `next_step` (the only insight whose text is written
|
|
39
|
+
to the CRM) the written action itself must be grounded in that quote — every
|
|
40
|
+
number/amount must appear in the quote, and the action's distinctive terms
|
|
41
|
+
must overlap it. This closes the prompt-injection path where a transcript
|
|
42
|
+
fabricates a malicious next step accompanied by an innocuous real quote. (This
|
|
43
|
+
is defense-in-depth on a human-approved path; a determined paraphrase-style
|
|
44
|
+
injection still surfaces to the approver as the proposed value.)
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- README now states the design as **deterministic apply, governed suggest** and
|
|
49
|
+
cites the current 1,020-run / five-model benchmark (was a stale 612-run line);
|
|
50
|
+
a CI guard fails if the documented synthetic-scenario count drifts from the
|
|
51
|
+
code.
|
|
52
|
+
|
|
8
53
|
## [0.26.0] — 2026-06-15
|
|
9
54
|
|
|
10
55
|
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
package/README.md
CHANGED
|
@@ -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
|
|
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
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
|
|
29
|
+
**Writes are approval-gated.** Reads are safe by default. Every change is a
|
|
30
|
+
typed patch operation in a dry-run plan that a human must approve before
|
|
31
|
+
`apply`. `apply` writes only operations whose ids were explicitly approved,
|
|
32
|
+
refuses operations carrying unresolved placeholder values, and uses
|
|
33
|
+
compare-and-set against the live CRM so a value that drifted since the plan was
|
|
34
|
+
built becomes a conflict, not a clobber. Irreversible operations (merge,
|
|
35
|
+
archive) get a fresh-snapshot drift guard, and archiving a record that still
|
|
36
|
+
shares an identity key with another is refused (it's a duplicate — merge it).
|
|
37
|
+
|
|
38
|
+
**Approval integrity.** At approval time each operation's apply-relevant content
|
|
39
|
+
is HMAC-signed with a per-install key (`$FSGTM_HOME/.plan-signing-key`, `0600`).
|
|
40
|
+
`apply --plan-id` re-verifies; a plan edited after approval — by a synced copy,
|
|
41
|
+
another process, or a compromised dependency — is refused rather than executed.
|
|
42
|
+
The invariant: **what gets written equals what the human signed.** A plan
|
|
43
|
+
approved on one machine cannot be applied on another (the key does not travel).
|
|
44
|
+
Documented boundary: this defends the plan file, not an attacker who already
|
|
45
|
+
holds the signing key (same directory and permissions as the credential store).
|
|
46
|
+
|
|
47
|
+
**Scheduling never auto-approves.** Scheduled (cron) runs are restricted to a
|
|
48
|
+
read/plan-side allowlist plus `apply --plan-id` whose approved status and
|
|
49
|
+
signatures are re-checked at every firing. Arbitrary shell is not schedulable.
|
|
50
|
+
|
|
51
|
+
**Untrusted input.** Competitor pages fetched by `market capture` are guarded
|
|
52
|
+
against SSRF (scheme allowlist; private/loopback/link-local/metadata addresses
|
|
53
|
+
refused; redirects re-validated). LLM-extracted call insights and market
|
|
54
|
+
classifications are mechanically verified verbatim against the source text
|
|
55
|
+
before they can drive a writeback, so a prompt-injected transcript or page
|
|
56
|
+
cannot fabricate a grounded-looking change. CSV/formula-injection in ingested
|
|
57
|
+
data is neutralized before it reaches a write.
|
|
58
|
+
|
|
59
|
+
**Auditability.** `audit-log export` produces a hash-chained, install-signed
|
|
60
|
+
record of every apply run for change-management/SIEM ingestion; `audit-log
|
|
61
|
+
verify` detects any edit or reorder.
|
|
62
|
+
|
|
63
|
+
## Data flows
|
|
64
|
+
|
|
65
|
+
What leaves the machine, to whom, and for which command is enumerated in
|
|
66
|
+
[DATA-FLOWS.md](./DATA-FLOWS.md). In brief: the core CLI is BYO-key and talks
|
|
67
|
+
directly to your CRM and (only for LLM/enrichment verbs you invoke) to your
|
|
68
|
+
chosen Anthropic/OpenAI/Apollo accounts — there is no fullstackgtm-operated
|
|
69
|
+
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;
|
package/dist/auditLog.js
ADDED
|
@@ -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
|
+
}
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { generateDemoSnapshot } from "./demo.js";
|
|
|
14
14
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
15
|
import { mergeSnapshots } from "./merge.js";
|
|
16
16
|
import { verifyApprovalDigests } from "./integrity.js";
|
|
17
|
+
import { buildAuditLog, verifyAuditLog } from "./auditLog.js";
|
|
17
18
|
import { createFilePlanStore } from "./planStore.js";
|
|
18
19
|
import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
19
20
|
import { builtinAuditRules } from "./rules.js";
|
|
@@ -155,6 +156,7 @@ Usage:
|
|
|
155
156
|
fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
|
|
156
157
|
fullstackgtm apply --plan-id <id> --provider <name>
|
|
157
158
|
fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
|
|
159
|
+
fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
|
|
158
160
|
fullstackgtm rules [--json]
|
|
159
161
|
fullstackgtm profiles [--json] list credential profiles
|
|
160
162
|
fullstackgtm doctor [--json] check install, credentials, and next step
|
|
@@ -2281,6 +2283,52 @@ function readSuggestionValues(path, minConfidence, includeCreates) {
|
|
|
2281
2283
|
}
|
|
2282
2284
|
return { overrides, skipped };
|
|
2283
2285
|
}
|
|
2286
|
+
async function auditLogCommand(args) {
|
|
2287
|
+
const [sub, ...rest] = args;
|
|
2288
|
+
if (!sub || sub === "--help" || sub === "-h" || (sub !== "export" && sub !== "verify")) {
|
|
2289
|
+
console.log(`Usage:
|
|
2290
|
+
audit-log export [--out <path>] [--json] hash-chained, signed record of every apply run
|
|
2291
|
+
audit-log verify [--in <path>] re-check an exported log's chain and signature
|
|
2292
|
+
|
|
2293
|
+
export flattens every apply run across all stored plans (this profile) into a
|
|
2294
|
+
tamper-evident chain — each entry carries the prior entry's hash, and the chain
|
|
2295
|
+
head is HMAC-signed with this install's key — so a change-management process can
|
|
2296
|
+
archive one file and later prove it was not edited. verify recomputes the chain
|
|
2297
|
+
and (if the signing key is present) the signature.`);
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
if (sub === "export") {
|
|
2301
|
+
const plans = await createFilePlanStore().list();
|
|
2302
|
+
const log = buildAuditLog(plans, new Date().toISOString());
|
|
2303
|
+
const payload = `${JSON.stringify(log, null, 2)}\n`;
|
|
2304
|
+
const outPath = option(rest, "--out");
|
|
2305
|
+
if (outPath) {
|
|
2306
|
+
writeFileSync(resolve(process.cwd(), outPath), payload);
|
|
2307
|
+
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)"}.`);
|
|
2308
|
+
}
|
|
2309
|
+
else if (rest.includes("--json")) {
|
|
2310
|
+
console.log(payload);
|
|
2311
|
+
}
|
|
2312
|
+
else {
|
|
2313
|
+
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.`);
|
|
2314
|
+
}
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
// verify
|
|
2318
|
+
const inPath = option(rest, "--in");
|
|
2319
|
+
if (!inPath)
|
|
2320
|
+
throw new Error("audit-log verify requires --in <exported-log.json>");
|
|
2321
|
+
const log = JSON.parse(readFileSync(resolve(process.cwd(), inPath), "utf8"));
|
|
2322
|
+
const result = verifyAuditLog(log);
|
|
2323
|
+
if (rest.includes("--json")) {
|
|
2324
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2325
|
+
}
|
|
2326
|
+
else {
|
|
2327
|
+
console.log(result.ok ? `OK — ${result.detail}` : `TAMPERED — ${result.detail}`);
|
|
2328
|
+
}
|
|
2329
|
+
if (!result.ok)
|
|
2330
|
+
process.exitCode = 2;
|
|
2331
|
+
}
|
|
2284
2332
|
async function apply(args) {
|
|
2285
2333
|
const provider = option(args, "--provider");
|
|
2286
2334
|
if (!provider)
|
|
@@ -3053,6 +3101,10 @@ export async function runCli(argv) {
|
|
|
3053
3101
|
await plansCommand(args);
|
|
3054
3102
|
return;
|
|
3055
3103
|
}
|
|
3104
|
+
if (command === "audit-log") {
|
|
3105
|
+
await auditLogCommand(args);
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3056
3108
|
if (command === "apply") {
|
|
3057
3109
|
await apply(args);
|
|
3058
3110
|
return;
|
package/dist/index.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type
|
|
|
17
17
|
export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
|
|
18
18
|
export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
|
|
19
19
|
export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, type ApprovalVerification, } from "./integrity.ts";
|
|
20
|
+
export { buildAuditLog, verifyAuditLog, type AuditLogEntry, type AuditLogExport, type AuditLogVerification, } from "./auditLog.ts";
|
|
20
21
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
21
22
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
22
23
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
|
|
|
17
17
|
export { mergeSnapshots, } from "./merge.js";
|
|
18
18
|
export { createFilePlanStore } from "./planStore.js";
|
|
19
19
|
export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, } from "./integrity.js";
|
|
20
|
+
export { buildAuditLog, verifyAuditLog, } from "./auditLog.js";
|
|
20
21
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
21
22
|
export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
22
23
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
|
package/dist/llm.js
CHANGED
|
@@ -70,8 +70,23 @@ export async function extractInsightsLlm(transcript, options) {
|
|
|
70
70
|
const text = truncateTranscript(transcript);
|
|
71
71
|
const prompt = `${EXTRACT_INSTRUCTIONS}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
|
|
72
72
|
const result = (await forcedToolCall(prompt, "extract_call_insights", EXTRACT_SCHEMA, model, options));
|
|
73
|
+
const normalizedTranscript = normalizeSpan(text);
|
|
73
74
|
const insights = (result.insights ?? [])
|
|
74
75
|
.filter((insight) => INSIGHT_TYPES.includes(insight.type))
|
|
76
|
+
// Mechanical verbatim gate (mirrors market classify): the prompt asks for a
|
|
77
|
+
// verbatim quote, but a prompt-injected or hallucinated transcript could
|
|
78
|
+
// fabricate a grounded-looking insight that drives a governed writeback.
|
|
79
|
+
// (1) The evidence quote must be a non-trivial verbatim span of the transcript.
|
|
80
|
+
.filter((insight) => {
|
|
81
|
+
const quote = normalizeSpan(insight.evidence ?? "");
|
|
82
|
+
return quote.length >= 12 && normalizedTranscript.includes(quote);
|
|
83
|
+
})
|
|
84
|
+
// (2) For next_step — the only insight type whose `text` is WRITTEN to the CRM
|
|
85
|
+
// (set_field nextStep / create_task body) — the written action must itself be
|
|
86
|
+
// grounded in the verified quote, not just accompanied by an innocuous one.
|
|
87
|
+
// This closes the decoupling attack: a prompt-injected transcript that emits a
|
|
88
|
+
// malicious `text` while quoting an unrelated real span no longer survives.
|
|
89
|
+
.filter((insight) => insight.type !== "next_step" || actionGroundedInEvidence(insight.text, insight.evidence ?? ""))
|
|
75
90
|
.map((insight) => ({
|
|
76
91
|
...insight,
|
|
77
92
|
title: insight.type.replace(/_/g, " "),
|
|
@@ -81,6 +96,39 @@ export async function extractInsightsLlm(transcript, options) {
|
|
|
81
96
|
.sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
|
|
82
97
|
return { insights, model };
|
|
83
98
|
}
|
|
99
|
+
/** Whitespace/punctuation-spacing-normalized match (same rule as market spans). */
|
|
100
|
+
function normalizeSpan(value) {
|
|
101
|
+
return value
|
|
102
|
+
.replace(/\s+([.,;:!?])/g, "$1")
|
|
103
|
+
.replace(/\s+/g, " ")
|
|
104
|
+
.trim()
|
|
105
|
+
.toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Is the written next-step action grounded in its (already transcript-verified)
|
|
109
|
+
* evidence quote? A legitimate next step paraphrases the quote, so it reuses the
|
|
110
|
+
* quote's salient terms; a prompt-injected action ("wire $50,000 to account
|
|
111
|
+
* 1234") quoting an unrelated innocuous span does not. Two checks: every
|
|
112
|
+
* number/amount in the action must appear in the evidence (defeats the
|
|
113
|
+
* financial-exfil class cleanly), and a meaningful share of the action's
|
|
114
|
+
* distinctive (≥4-char) words must appear in the evidence.
|
|
115
|
+
*/
|
|
116
|
+
function actionGroundedInEvidence(text, evidence) {
|
|
117
|
+
const action = normalizeSpan(text);
|
|
118
|
+
const quote = normalizeSpan(evidence);
|
|
119
|
+
if (!action)
|
|
120
|
+
return false;
|
|
121
|
+
const numbers = action.match(/\d[\d,.]*/g) ?? [];
|
|
122
|
+
for (const n of numbers) {
|
|
123
|
+
if (!quote.includes(n))
|
|
124
|
+
return false; // an ungrounded amount/account/id is a red flag
|
|
125
|
+
}
|
|
126
|
+
const distinctive = [...new Set(action.split(/[^a-z0-9$]+/).filter((token) => token.length >= 4))];
|
|
127
|
+
if (distinctive.length === 0)
|
|
128
|
+
return true; // nothing distinctive to ground (a short generic step)
|
|
129
|
+
const grounded = distinctive.filter((token) => quote.includes(token)).length;
|
|
130
|
+
return grounded / distinctive.length >= 0.4;
|
|
131
|
+
}
|
|
84
132
|
export const DEFAULT_RUBRIC = {
|
|
85
133
|
scale: 5,
|
|
86
134
|
dimensions: [
|
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/cli.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { generateDemoSnapshot } from "./demo.ts";
|
|
|
35
35
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
36
36
|
import { mergeSnapshots } from "./merge.ts";
|
|
37
37
|
import { verifyApprovalDigests } from "./integrity.ts";
|
|
38
|
+
import { buildAuditLog, verifyAuditLog } from "./auditLog.ts";
|
|
38
39
|
import { createFilePlanStore } from "./planStore.ts";
|
|
39
40
|
import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
40
41
|
import { builtinAuditRules } from "./rules.ts";
|
|
@@ -254,6 +255,7 @@ Usage:
|
|
|
254
255
|
fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
|
|
255
256
|
fullstackgtm apply --plan-id <id> --provider <name>
|
|
256
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
|
|
257
259
|
fullstackgtm rules [--json]
|
|
258
260
|
fullstackgtm profiles [--json] list credential profiles
|
|
259
261
|
fullstackgtm doctor [--json] check install, credentials, and next step
|
|
@@ -2558,6 +2560,50 @@ function readSuggestionValues(path: string, minConfidence: string, includeCreate
|
|
|
2558
2560
|
return { overrides, skipped };
|
|
2559
2561
|
}
|
|
2560
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
|
+
|
|
2561
2607
|
async function apply(args: string[]) {
|
|
2562
2608
|
const provider = option(args, "--provider");
|
|
2563
2609
|
if (!provider) throw new Error("apply requires --provider <name>");
|
|
@@ -3416,6 +3462,10 @@ export async function runCli(argv: string[]) {
|
|
|
3416
3462
|
await plansCommand(args);
|
|
3417
3463
|
return;
|
|
3418
3464
|
}
|
|
3465
|
+
if (command === "audit-log") {
|
|
3466
|
+
await auditLogCommand(args);
|
|
3467
|
+
return;
|
|
3468
|
+
}
|
|
3419
3469
|
if (command === "apply") {
|
|
3420
3470
|
await apply(args);
|
|
3421
3471
|
return;
|
package/src/index.ts
CHANGED
|
@@ -123,6 +123,13 @@ export {
|
|
|
123
123
|
verifyApprovalDigests,
|
|
124
124
|
type ApprovalVerification,
|
|
125
125
|
} from "./integrity.ts";
|
|
126
|
+
export {
|
|
127
|
+
buildAuditLog,
|
|
128
|
+
verifyAuditLog,
|
|
129
|
+
type AuditLogEntry,
|
|
130
|
+
type AuditLogExport,
|
|
131
|
+
type AuditLogVerification,
|
|
132
|
+
} from "./auditLog.ts";
|
|
126
133
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
127
134
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
128
135
|
export {
|
package/src/llm.ts
CHANGED
|
@@ -109,8 +109,23 @@ export async function extractInsightsLlm(
|
|
|
109
109
|
const result = (await forcedToolCall(prompt, "extract_call_insights", EXTRACT_SCHEMA, model, options)) as {
|
|
110
110
|
insights?: LlmExtractedInsight[];
|
|
111
111
|
};
|
|
112
|
+
const normalizedTranscript = normalizeSpan(text);
|
|
112
113
|
const insights = (result.insights ?? [])
|
|
113
114
|
.filter((insight) => INSIGHT_TYPES.includes(insight.type))
|
|
115
|
+
// Mechanical verbatim gate (mirrors market classify): the prompt asks for a
|
|
116
|
+
// verbatim quote, but a prompt-injected or hallucinated transcript could
|
|
117
|
+
// fabricate a grounded-looking insight that drives a governed writeback.
|
|
118
|
+
// (1) The evidence quote must be a non-trivial verbatim span of the transcript.
|
|
119
|
+
.filter((insight) => {
|
|
120
|
+
const quote = normalizeSpan(insight.evidence ?? "");
|
|
121
|
+
return quote.length >= 12 && normalizedTranscript.includes(quote);
|
|
122
|
+
})
|
|
123
|
+
// (2) For next_step — the only insight type whose `text` is WRITTEN to the CRM
|
|
124
|
+
// (set_field nextStep / create_task body) — the written action must itself be
|
|
125
|
+
// grounded in the verified quote, not just accompanied by an innocuous one.
|
|
126
|
+
// This closes the decoupling attack: a prompt-injected transcript that emits a
|
|
127
|
+
// malicious `text` while quoting an unrelated real span no longer survives.
|
|
128
|
+
.filter((insight) => insight.type !== "next_step" || actionGroundedInEvidence(insight.text, insight.evidence ?? ""))
|
|
114
129
|
.map((insight) => ({
|
|
115
130
|
...insight,
|
|
116
131
|
title: insight.type.replace(/_/g, " "),
|
|
@@ -121,6 +136,38 @@ export async function extractInsightsLlm(
|
|
|
121
136
|
return { insights, model };
|
|
122
137
|
}
|
|
123
138
|
|
|
139
|
+
/** Whitespace/punctuation-spacing-normalized match (same rule as market spans). */
|
|
140
|
+
function normalizeSpan(value: string): string {
|
|
141
|
+
return value
|
|
142
|
+
.replace(/\s+([.,;:!?])/g, "$1")
|
|
143
|
+
.replace(/\s+/g, " ")
|
|
144
|
+
.trim()
|
|
145
|
+
.toLowerCase();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Is the written next-step action grounded in its (already transcript-verified)
|
|
150
|
+
* evidence quote? A legitimate next step paraphrases the quote, so it reuses the
|
|
151
|
+
* quote's salient terms; a prompt-injected action ("wire $50,000 to account
|
|
152
|
+
* 1234") quoting an unrelated innocuous span does not. Two checks: every
|
|
153
|
+
* number/amount in the action must appear in the evidence (defeats the
|
|
154
|
+
* financial-exfil class cleanly), and a meaningful share of the action's
|
|
155
|
+
* distinctive (≥4-char) words must appear in the evidence.
|
|
156
|
+
*/
|
|
157
|
+
function actionGroundedInEvidence(text: string, evidence: string): boolean {
|
|
158
|
+
const action = normalizeSpan(text);
|
|
159
|
+
const quote = normalizeSpan(evidence);
|
|
160
|
+
if (!action) return false;
|
|
161
|
+
const numbers = action.match(/\d[\d,.]*/g) ?? [];
|
|
162
|
+
for (const n of numbers) {
|
|
163
|
+
if (!quote.includes(n)) return false; // an ungrounded amount/account/id is a red flag
|
|
164
|
+
}
|
|
165
|
+
const distinctive = [...new Set(action.split(/[^a-z0-9$]+/).filter((token) => token.length >= 4))];
|
|
166
|
+
if (distinctive.length === 0) return true; // nothing distinctive to ground (a short generic step)
|
|
167
|
+
const grounded = distinctive.filter((token) => quote.includes(token)).length;
|
|
168
|
+
return grounded / distinctive.length >= 0.4;
|
|
169
|
+
}
|
|
170
|
+
|
|
124
171
|
// ── Rubric scoring ─────────────────────────────────────────────────────────
|
|
125
172
|
|
|
126
173
|
export type Rubric = {
|