srclawyer 0.6.2 → 0.7.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Static analysis engine that derives privacy policy documentation directly from source code, and keeps it in sync as the code changes.
4
4
 
5
- > **Beta.** The CLI (`init`, `scan`), the Layer 1 static analysis engine, and markdown privacy policy generation all work today. JavaScript/TypeScript (Express, Next.js) is the most tested path; Python (Flask) is newer, with FastAPI and Django explicitly detected and flagged as unsupported rather than silently skipped — see [Supported languages and frameworks](#supported-languages-and-frameworks) and [Known gaps](#known-gaps) below. LLM-assisted reasoning for ambiguous findings (Layer 2) is available now under the bring-your-own-key tier, documented below as one optional step in `init` — a managed (hosted) tier is planned but not yet available.
5
+ > **Beta.** The CLI (`init`, `scan`), the Layer 1 static analysis engine, and markdown privacy policy generation all work today. JavaScript/TypeScript (Express, Next.js) is the most tested path; Python (Flask) is newer, with FastAPI and Django explicitly detected and flagged as unsupported rather than silently skipped — see [Supported languages and frameworks](#supported-languages-and-frameworks) and [Known gaps](#known-gaps) below. Optional AI-assisted review of ambiguous findings (Layer 2) is available as an advanced option with your own Anthropic API key — see [AI-assisted review](#ai-assisted-review-advanced) below; a hosted tier that needs no key is planned but not yet available.
6
6
  >
7
7
  > Scanning is manual-only (`srclawyer scan`, run on demand) — this is the free/solo tier's permanent behavior, not a placeholder. Automatic CI/CD updating (a scan that runs itself on every PR, or on a schedule) isn't available yet; it's scoped for a future paid/team tier. See `PRODUCT_SPEC.md` Section 8.5 for the plan.
8
8
 
@@ -93,9 +93,65 @@ Every finding traces back to a file and line (`src/engine/types.ts#Finding`).
93
93
  - **Flask support covers direct request reads and same-function aliases only.** It detects `request.form[...]`, `request.args.get(...)`, `request.json[...]` and the like, plus one assignment hop within a function (`data = request.get_json()`, then `data['x']`). Form classes — Flask-WTF/WTForms `FlaskForm` subclasses with `StringField`/`PasswordField` declarations read via `form.email.data` — are not detected, and neither are fields rendered by template helpers such as `{{ form.email() }}`. A Flask app that collects data through WTForms may therefore show fewer collected fields than it actually has.
94
94
  - **Server-side template syntax inside HTML is not analyzed.** Jinja, Django, ERB and similar delimiters (`{{`, `{%`, `<%`, `{#`) are not interpreted: an inline `<script>` that isn't valid JavaScript until the template engine runs is skipped (listed in `--json` as `template-syntax`, with a one-line note), and form fields that a template generates rather than spells out as `<input>` tags are not seen.
95
95
 
96
+ ## AI-assisted review (advanced)
97
+
98
+ Off by default, and not part of `init`'s questions. `scan` already surfaces anything it can't confidently classify under "Items Requiring Manual Review"; with AI-assisted review enabled it first asks Anthropic, under **your own API key**, to classify each such item. For each item Anthropic receives: an opaque per-scan id, the item's data category, its short description (which names the field), and the one source line it was found on with secrets redacted — plus a fixed instruction prompt and the model name. **Not sent:** the file's path, its line number, or any surrounding code (the source line itself goes as written, apart from redacted secrets). Items the model declines stay in the manual-review list, and items involving children's data are never auto-resolved.
99
+
100
+ Enable it when creating the config:
101
+
102
+ ```bash
103
+ srclawyer init --enable-ai-review # asks which env var holds your key (default ANTHROPIC_API_KEY)
104
+ srclawyer init --enable-ai-review --api-key-env-var MY_KEY # same, non-interactive
105
+ export ANTHROPIC_API_KEY=... # or whichever name you chose
106
+ srclawyer scan
107
+ ```
108
+
109
+ or, for an existing config (`init` never overwrites one), add this to `.srclawyer.config.yml` by hand:
110
+
111
+ ```yaml
112
+ tier: byok
113
+ llm:
114
+ provider: anthropic
115
+ apiKeyEnvVar: ANTHROPIC_API_KEY
116
+ ```
117
+
118
+ Only the variable's *name* is stored in the config — the key itself is never written to disk. When it's enabled, `scan` prints a notice that redacted snippets are being sent to Anthropic; `scan --no-llm` skips it for a single run. A hosted tier, where SrcLawyer supplies the model access and no key is needed, is planned but not yet available: a config with `tier: managed` gets a clear "not available yet" error rather than silently doing nothing.
119
+
120
+ ## Drift check (GitHub Action)
121
+
122
+ **Free during the beta.** Flags when your code starts collecting a new kind of data, or using a new third-party processor, that your privacy policy was never updated for. It compares the code against a committed baseline, `.srclawyer.inventory.json` — just the sorted data categories and processors, no file names or line numbers — not against policy text, which people edit by hand.
123
+
124
+ ```bash
125
+ npx srclawyer scan --write-inventory # once: writes the policy and the baseline; commit the baseline
126
+ npx srclawyer check # exit 0 no drift, 1 drift, 2 error, 3 inconclusive
127
+ ```
128
+
129
+ ```yaml
130
+ - uses: actions/checkout@v7
131
+ - uses: SrcLawyer/srclawyer@v0.7.0
132
+ with:
133
+ srclawyer-version: 0.7.0 # required and pinned
134
+ ```
135
+
136
+ Drift fails the check, and the step summary lists what was added and removed with a one-line fix. If no verdict can be reached — the confidence service is unreachable, files were skipped, or the baseline and the run used different file discovery — the result is **inconclusive**, a warning by default, and is never reported as drift. The Action needs no secrets and `contents: read`; an optional pull-request comment needs `pull-requests: write`. Inside a git repository, `check` and `--write-inventory` look only at tracked and untracked-but-not-ignored files, so a gitignored build directory can't cause phantom drift; plain `scan` is unchanged. `check` makes the same network calls as `scan` (below) and never calls an AI provider.
137
+
138
+ | Exit code | `srclawyer check` | `srclawyer scan` |
139
+ |---|---|---|
140
+ | 0 | no drift | clean |
141
+ | 1 | drift | completed with warnings |
142
+ | 2 | crash, bad usage, or a missing or invalid baseline | crash or bad usage |
143
+ | 3 | inconclusive | — |
144
+
145
+ `scan`'s "completed with warnings" (1) and `check`'s "inconclusive" (3) are the same condition under different numbers; `scan`'s was left alone so existing scripts keep working. Example workflows (pull requests, a weekly schedule, manual dispatch, writing the baseline on the runner) are in [docs/github-action.md](docs/github-action.md).
146
+
96
147
  ## Network calls
97
148
 
98
- Detection itself is fully local and offline — no code leaves your machine for pattern matching. Once detection completes, `scan` makes one batched network call to grade the confidence of what it found (field names and derived yes/no signals only, never source code), and, for scans that use Zod schemas as request validators, one additional call sending a small redacted code fragment. This runs on every scan regardless of tier and is unrelated to the separate, optional LLM-assisted (Layer 2) reasoning described above. See `PRODUCT_SPEC.md`'s "Static Analysis Depth" section for the precise wording.
149
+ Detection itself is fully local and offline — no code leaves your machine for pattern matching. Once detection completes, every `scan` makes these calls to a SrcLawyer-run service:
150
+
151
+ - **Confidence grading** (one batched call). For each detected field: an opaque per-scan id (`f0`, `f1`, …), the data category its name maps to (e.g. `email`), and three derived signals — whether the name is ambiguous, whether it was read directly from the request in code (rather than inferred from a type), and how many confidently classified fields sit alongside it. **Not sent:** the field's name, file paths, line numbers, or source code.
152
+ - **Zod-schema detection** (one more batched call, only for scans where Zod schemas appear to validate request data). Per schema: an opaque id (`c0`, `c1`, …) and a small, secret-redacted code fragment — the schema, the call that validates with it, and the imports it uses, as source text (so it includes the schema's field names and those import specifiers). **Not sent:** the schema's file path or line number.
153
+
154
+ Ids are numbered per scan; the mapping back to files and lines never leaves your machine. Requests carry a shared API key (`x-api-key`) and the standard headers Node.js adds (such as `user-agent: node`); there is no install ID, machine information, or telemetry. These calls run on every scan, with or without the optional [AI-assisted review](#ai-assisted-review-advanced), and are separate from it. See `PRODUCT_SPEC.md`'s "Static Analysis Depth" section for the same wording.
99
155
 
100
156
  ## Secret handling
101
157
 
package/dist/cli.js CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { Command } from "commander";
6
- import { runInit } from "./commands/init.js";
2
+ import { Command, CommanderError } from "commander";
3
+ import { isValidEnvVarName, runInit } from "./commands/init.js";
7
4
  import { runScan } from "./commands/scan.js";
5
+ import { runCheck } from "./commands/check.js";
6
+ import { DEFAULT_INVENTORY_PATH } from "./inventory/inventory.js";
7
+ import { EXIT_CRASH } from "./exitCodes.js";
8
+ import { readPackageVersion } from "./version.js";
8
9
  // Read from package.json rather than hardcoding, so --version can't silently drift out of sync with
9
10
  // the published version the way a literal string did through every prior release.
10
- const HERE = dirname(fileURLToPath(import.meta.url));
11
- const { version } = JSON.parse(readFileSync(join(HERE, "../package.json"), "utf8"));
11
+ const version = readPackageVersion();
12
12
  const program = new Command();
13
+ // Commander normally calls process.exit(1) itself on a usage error; exit codes are this CLI's contract
14
+ // (see exitCodes.ts), so it throws instead and the handler at the bottom decides the code.
15
+ program.exitOverride();
13
16
  program
14
17
  .name("srclawyer")
15
18
  .description("Static analysis engine that derives privacy policy documentation directly from source code")
@@ -37,7 +40,15 @@ program
37
40
  .option("--minimum-age <number>", "minimum age required to use this product")
38
41
  .option("--sends-data-to-ai-services", "declare that user data is sent to third-party AI services")
39
42
  .option("--no-sends-data-to-ai-services", "declare that user data is not sent to third-party AI services")
43
+ .option("--enable-ai-review", "advanced: enable optional AI-assisted review of ambiguous findings with your own Anthropic API key (sets tier: byok)")
44
+ .option("--api-key-env-var <name>", "with --enable-ai-review: the environment variable holding your Anthropic API key (default ANTHROPIC_API_KEY)")
40
45
  .action(async (opts) => {
46
+ if (opts.apiKeyEnvVar !== undefined) {
47
+ if (!opts.enableAiReview)
48
+ program.error("error: --api-key-env-var requires --enable-ai-review");
49
+ if (!isValidEnvVarName(opts.apiKeyEnvVar))
50
+ program.error(`error: --api-key-env-var must be a valid environment variable name (got "${opts.apiKeyEnvVar}")`);
51
+ }
41
52
  let minimumAge;
42
53
  if (opts.minimumAge !== undefined) {
43
54
  const parsed = Number(opts.minimumAge);
@@ -60,6 +71,8 @@ program
60
71
  accountDeletionInstructions: opts.accountDeletionInstructions,
61
72
  minimumAge,
62
73
  sendsDataToAiServices: opts.sendsDataToAiServices,
74
+ enableAiReview: opts.enableAiReview,
75
+ apiKeyEnvVar: opts.apiKeyEnvVar,
63
76
  });
64
77
  });
65
78
  program
@@ -68,7 +81,41 @@ program
68
81
  .option("--json", "output findings as JSON", false)
69
82
  .option("--out <path>", "path to write the generated privacy policy", "PRIVACY_POLICY.md")
70
83
  .option("--no-llm", "skip AI-assisted resolution of ambiguous findings for this run")
84
+ .option("--write-inventory [path]", `also write the drift baseline (data categories and processors only) to <path> (default ${DEFAULT_INVENTORY_PATH}); uses git-aware file discovery in a git repository`)
85
+ .option("--allow-incomplete-inventory", "with --write-inventory: write the baseline even if the scan completed with warnings")
71
86
  .action(async (opts) => {
72
- await runScan(process.cwd(), { json: opts.json, out: opts.out, llm: opts.llm });
87
+ await runScan(process.cwd(), {
88
+ json: opts.json,
89
+ out: opts.out,
90
+ llm: opts.llm,
91
+ writeInventory: opts.writeInventory === true ? DEFAULT_INVENTORY_PATH : opts.writeInventory,
92
+ allowIncompleteInventory: opts.allowIncompleteInventory,
93
+ });
94
+ });
95
+ program
96
+ .command("check")
97
+ .description("Check for drift: compare the code's data categories and processors with the committed baseline (exit 0 no drift, 1 drift, 2 error, 3 inconclusive)")
98
+ .option("--inventory <path>", "the baseline file", DEFAULT_INVENTORY_PATH)
99
+ .option("--format <format>", "text or json", "text")
100
+ .action(async (opts) => {
101
+ const format = opts.format;
102
+ if (format !== "text" && format !== "json")
103
+ return program.error(`error: --format must be "text" or "json" (got "${format}")`);
104
+ process.exitCode = await runCheck(process.cwd(), { inventoryPath: opts.inventory, format });
105
+ });
106
+ // Exit 1 is reserved for "completed with warnings" (scan) and "drift" (check), so anything that ends
107
+ // the run abnormally -- an uncaught error, an unhandled rejection, a usage error -- exits 2.
108
+ function crash(error) {
109
+ console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
110
+ process.exit(EXIT_CRASH);
111
+ }
112
+ process.on("uncaughtException", crash);
113
+ process.on("unhandledRejection", crash);
114
+ program.parseAsync(process.argv).catch((error) => {
115
+ if (error instanceof CommanderError) {
116
+ // Help and --version also arrive here, with exitCode 0; commander has already printed its message.
117
+ process.exitCode = error.exitCode === 0 ? 0 : EXIT_CRASH;
118
+ return;
119
+ }
120
+ crash(error);
73
121
  });
74
- program.parseAsync(process.argv);
@@ -39,6 +39,22 @@ function chunkByCountAndBytes(items, maxCount, maxBytes) {
39
39
  batches.push(current);
40
40
  return batches;
41
41
  }
42
+ /**
43
+ * The one place ids are assigned for anything sent to the Worker. Callers identify items by their own
44
+ * ids (finding ids embed file path, line and field name; Zod candidate ids ARE the file path), but
45
+ * those never leave this process: every item goes out under an opaque per-scan id (f0, f1, ... /
46
+ * c0, c1, ...), unique across all batches, and responses are mapped back here. The Worker only echoes
47
+ * ids (it never parses them), so nothing about the wire format depends on their shape.
48
+ */
49
+ function toOpaque(items, prefix) {
50
+ const originalByOpaque = new Map();
51
+ const wire = items.map((item, i) => {
52
+ const opaque = `${prefix}${i}`;
53
+ originalByOpaque.set(opaque, item.id);
54
+ return { ...item, id: opaque };
55
+ });
56
+ return { wire, originalByOpaque };
57
+ }
42
58
  function sleep(ms) {
43
59
  return new Promise((resolve) => setTimeout(resolve, ms));
44
60
  }
@@ -79,11 +95,15 @@ export async function scoreFields(items, options) {
79
95
  const batchMaxBytes = options.batchMaxBytes ?? DEFAULT_BATCH_MAX_BYTES;
80
96
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
81
97
  let error = null;
82
- for (const batch of chunkByCountAndBytes(items, batchSize, batchMaxBytes)) {
98
+ const { wire, originalByOpaque } = toOpaque(items, "f");
99
+ for (const batch of chunkByCountAndBytes(wire, batchSize, batchMaxBytes)) {
83
100
  try {
84
101
  const response = await postJson(`${options.baseUrl}/v1/score-fields`, options.apiKey, { fields: batch }, timeoutMs);
85
- for (const result of response.results)
86
- results.set(result.id, result);
102
+ for (const result of response.results) {
103
+ const original = originalByOpaque.get(result.id);
104
+ if (original !== undefined)
105
+ results.set(original, { ...result, id: original });
106
+ }
87
107
  }
88
108
  catch (err) {
89
109
  error = err instanceof Error ? err.message : String(err);
@@ -99,11 +119,15 @@ export async function detectZodSchemas(items, options) {
99
119
  const batchMaxBytes = options.batchMaxBytes ?? DEFAULT_BATCH_MAX_BYTES;
100
120
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
101
121
  let error = null;
102
- for (const batch of chunkByCountAndBytes(items, batchSize, batchMaxBytes)) {
122
+ const { wire, originalByOpaque } = toOpaque(items, "c");
123
+ for (const batch of chunkByCountAndBytes(wire, batchSize, batchMaxBytes)) {
103
124
  try {
104
125
  const response = await postJson(`${options.baseUrl}/v1/detect-zod-schema`, options.apiKey, { candidates: batch }, timeoutMs);
105
- for (const result of response.results)
106
- results.set(result.id, result);
126
+ for (const result of response.results) {
127
+ const original = originalByOpaque.get(result.id);
128
+ if (original !== undefined)
129
+ results.set(original, { ...result, id: original });
130
+ }
107
131
  }
108
132
  catch (err) {
109
133
  error = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,28 @@
1
+ import { type DiscoveryMode, type InventoryDiff } from "../inventory/inventory.js";
2
+ export type CheckFormat = "text" | "json";
3
+ export interface CheckOptions {
4
+ inventoryPath: string;
5
+ format: CheckFormat;
6
+ }
7
+ export interface CheckReport {
8
+ result: "no-drift" | "drift" | "inconclusive" | "error";
9
+ exitCode: number;
10
+ srclawyerVersion: string;
11
+ baseline: {
12
+ path: string;
13
+ srclawyerVersion: string;
14
+ discovery: DiscoveryMode;
15
+ } | null;
16
+ currentDiscovery: DiscoveryMode | null;
17
+ /** Present only for a conclusive result -- an inconclusive run never reports a diff. */
18
+ diff: InventoryDiff | null;
19
+ inconclusiveReasons: string[];
20
+ error: string | null;
21
+ }
22
+ /**
23
+ * Re-derives the inventory and compares it with the committed baseline. Never writes a policy, never
24
+ * calls an LLM provider, and never reports an incomplete scan as drift: anything that makes the scan
25
+ * untrustworthy (the same warnings that make `scan` exit 1, or a discovery-mode mismatch) is
26
+ * inconclusive and carries no diff.
27
+ */
28
+ export declare function runCheck(root: string, options: CheckOptions): Promise<number>;
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { scan } from "../engine/scanEngine.js";
4
+ import { selectDiscovery } from "../engine/gitDiscovery.js";
5
+ import { resolveScanProtectedLogic } from "./protectedLogicStep.js";
6
+ import { computeInventory, diffInventories, hasDrift, inventoryBlockers, parseInventory, } from "../inventory/inventory.js";
7
+ import { EXIT_CRASH, EXIT_DRIFT, EXIT_INCONCLUSIVE, EXIT_OK } from "../exitCodes.js";
8
+ import { readPackageVersion } from "../version.js";
9
+ const FIX_INSTRUCTION = 'If this change is intended, update your privacy policy, then refresh the baseline with "srclawyer scan --write-inventory" and commit it.';
10
+ function listLine(label, values) {
11
+ return values.length > 0 ? [` ${label} ${values.join(", ")}`] : [];
12
+ }
13
+ function renderText(report, inventoryPath) {
14
+ const lines = [];
15
+ switch (report.result) {
16
+ case "no-drift":
17
+ lines.push("Drift check: no drift.");
18
+ lines.push(` Baseline ${inventoryPath} (srclawyer ${report.baseline?.srclawyerVersion}, ${report.baseline?.discovery} discovery) matches the code.`);
19
+ break;
20
+ case "drift": {
21
+ lines.push("Drift check: DRIFT DETECTED.");
22
+ const d = report.diff;
23
+ lines.push(...listLine("Added categories: ", d.added.categories), ...listLine("Removed categories:", d.removed.categories));
24
+ lines.push(...listLine("Added processors: ", d.added.processors), ...listLine("Removed processors:", d.removed.processors));
25
+ if (report.baseline && report.baseline.srclawyerVersion !== report.srclawyerVersion) {
26
+ lines.push(` (The baseline was written by srclawyer ${report.baseline.srclawyerVersion}; this is ${report.srclawyerVersion}. Detection rules can change between versions.)`);
27
+ }
28
+ lines.push("", FIX_INSTRUCTION);
29
+ break;
30
+ }
31
+ case "inconclusive":
32
+ lines.push("Drift check: INCONCLUSIVE — no drift verdict, because the scan was not complete or comparable:");
33
+ for (const reason of report.inconclusiveReasons)
34
+ lines.push(` - ${reason}`);
35
+ break;
36
+ case "error":
37
+ lines.push(`error: ${report.error}`);
38
+ break;
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+ function finish(report, options) {
43
+ if (options.format === "json")
44
+ console.log(JSON.stringify(report, null, 2));
45
+ else
46
+ (report.result === "error" ? console.error : console.log)(renderText(report, options.inventoryPath));
47
+ return report.exitCode;
48
+ }
49
+ const baseReport = () => ({
50
+ result: "error",
51
+ exitCode: EXIT_CRASH,
52
+ srclawyerVersion: readPackageVersion(),
53
+ baseline: null,
54
+ currentDiscovery: null,
55
+ diff: null,
56
+ inconclusiveReasons: [],
57
+ error: null,
58
+ });
59
+ /**
60
+ * Re-derives the inventory and compares it with the committed baseline. Never writes a policy, never
61
+ * calls an LLM provider, and never reports an incomplete scan as drift: anything that makes the scan
62
+ * untrustworthy (the same warnings that make `scan` exit 1, or a discovery-mode mismatch) is
63
+ * inconclusive and carries no diff.
64
+ */
65
+ export async function runCheck(root, options) {
66
+ const path = resolve(root, options.inventoryPath);
67
+ if (!existsSync(path)) {
68
+ return finish({ ...baseReport(), error: `no baseline found at ${options.inventoryPath}. Create one with "srclawyer scan --write-inventory" and commit it.` }, options);
69
+ }
70
+ const parsed = parseInventory(readFileSync(path, "utf8"));
71
+ if (!parsed.ok) {
72
+ return finish({ ...baseReport(), error: `the baseline at ${options.inventoryPath} is invalid: ${parsed.reason}. Re-create it with "srclawyer scan --write-inventory".` }, options);
73
+ }
74
+ const baseline = parsed.inventory;
75
+ const discovery = selectDiscovery(root);
76
+ const result = await scan(root, { onlyFiles: discovery.files });
77
+ const protectedLogic = await resolveScanProtectedLogic(result);
78
+ const reasons = inventoryBlockers(result, protectedLogic.warning);
79
+ if (baseline.discovery !== discovery.mode) {
80
+ reasons.push(`Discovery mode mismatch: the baseline was written with ${baseline.discovery} discovery, but this run used ${discovery.mode} discovery${discovery.mode === "glob" ? ` (${discovery.reason})` : ""}. The two see different files, so they can't be compared.`);
81
+ }
82
+ const report = {
83
+ ...baseReport(),
84
+ baseline: { path: options.inventoryPath, srclawyerVersion: baseline.srclawyerVersion, discovery: baseline.discovery },
85
+ currentDiscovery: discovery.mode,
86
+ };
87
+ if (reasons.length > 0) {
88
+ return finish({ ...report, result: "inconclusive", exitCode: EXIT_INCONCLUSIVE, inconclusiveReasons: reasons }, options);
89
+ }
90
+ const current = computeInventory(protectedLogic.findings, { srclawyerVersion: readPackageVersion(), discovery: discovery.mode });
91
+ const diff = diffInventories(baseline, current);
92
+ return finish(hasDrift(diff) ? { ...report, result: "drift", exitCode: EXIT_DRIFT, diff } : { ...report, result: "no-drift", exitCode: EXIT_OK, diff }, options);
93
+ }
@@ -12,5 +12,10 @@ export interface InitFlags {
12
12
  accountDeletionInstructions?: string;
13
13
  minimumAge?: number;
14
14
  sendsDataToAiServices?: boolean;
15
+ /** Advanced, opt-in: sets tier: byok. Never asked about in the default flow. */
16
+ enableAiReview?: boolean;
17
+ apiKeyEnvVar?: string;
15
18
  }
19
+ export declare const DEFAULT_API_KEY_ENV_VAR = "ANTHROPIC_API_KEY";
20
+ export declare function isValidEnvVarName(name: string): boolean;
16
21
  export declare function runInit(root: string, flags?: InitFlags): Promise<void>;
@@ -2,8 +2,13 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { CONFIG_FILENAME, DEFAULT_CONFIG, configPath, hasUnmigratedLegacyConfig, legacyConfigMessage, writeConfig, } from "../config/config.js";
5
- import { NETWORK_CALLS_NOTICE } from "../policy/legalNotices.js";
5
+ import { NETWORK_CALLS_NOTICE, byokNetworkNotice } from "../policy/legalNotices.js";
6
6
  import { runScan } from "./scan.js";
7
+ export const DEFAULT_API_KEY_ENV_VAR = "ANTHROPIC_API_KEY";
8
+ const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+ export function isValidEnvVarName(name) {
10
+ return ENV_VAR_NAME.test(name);
11
+ }
7
12
  function guessIndustry(root) {
8
13
  const pkgPath = join(root, "package.json");
9
14
  if (!existsSync(pkgPath))
@@ -132,17 +137,22 @@ export async function runInit(root, flags = {}) {
132
137
  // Deliberately about the SCANNED PRODUCT's own behavior (does it send its end-users' data to AI
133
138
  // services as part of its own functionality) -- not about SrcLawyer's own, separately-documented
134
139
  // BYOK network calls described in NETWORK_CALLS_NOTICE above.
135
- const sendsDataToAiServices = await resolveField(flags.sendsDataToAiServices, gateYes, () => askYesNoOptional(lines, "Does this product send user data to third-party AI services (e.g. OpenAI, Anthropic) as part of its own functionality?"));
136
- // "managed" is deliberately not offered here -- it's a real SrcLawyerTier value and config.ts/
137
- // resolveProviderForTier.ts both still support it fully (a hand-edited config with tier: managed
138
- // still gets a clear, accurate error at scan time), but there's no advertised path to it from this
139
- // prompt, so a tester reading the choices here can't select a dead end during setup.
140
- const enableByokRaw = await ask(lines, "Enable optional AI-assisted review with your own Anthropic key? (advanced)", "n");
141
- const tier = /^y(es)?$/i.test(enableByokRaw.trim()) ? "byok" : "free";
140
+ const sendsDataToAiServices = await resolveField(flags.sendsDataToAiServices, gateYes, () => askYesNoOptional(lines, "Does this product send user data to third-party AI services as part of its own functionality?"));
141
+ // AI-assisted review (tier: byok) is an advanced opt-in, reached only via --enable-ai-review (or by
142
+ // hand-editing the config) -- the default flow never asks about it or mentions an API key. "managed"
143
+ // is likewise not offered here: it's still a real SrcLawyerTier value that config.ts and
144
+ // resolveProviderForTier.ts support, so a hand-edited tier: managed keeps its clear "not available
145
+ // yet" error at scan time, but nothing advertises a path to it.
146
+ const tier = flags.enableAiReview ? "byok" : "free";
142
147
  let apiKeyEnvVar = null;
143
148
  if (tier === "byok") {
144
- apiKeyEnvVar = await ask(lines, "Which environment variable holds your Anthropic API key?", "ANTHROPIC_API_KEY");
149
+ apiKeyEnvVar = flags.apiKeyEnvVar ?? (await ask(lines, "Which environment variable holds your Anthropic API key?", DEFAULT_API_KEY_ENV_VAR));
150
+ if (!isValidEnvVarName(apiKeyEnvVar)) {
151
+ console.log(`\n"${apiKeyEnvVar}" is not a valid environment variable name; using ${DEFAULT_API_KEY_ENV_VAR} instead. Edit ${CONFIG_FILENAME} to change it.`);
152
+ apiKeyEnvVar = DEFAULT_API_KEY_ENV_VAR;
153
+ }
145
154
  console.log(`\nNote: only the name "${apiKeyEnvVar}" is written to ${CONFIG_FILENAME} — the key itself is never written to disk. It's read from your environment at scan time.`);
155
+ console.log(byokNetworkNotice(apiKeyEnvVar));
146
156
  }
147
157
  const config = {
148
158
  entityLocation: entityLocation || null,
@@ -0,0 +1,12 @@
1
+ import type { Finding, ScanResult } from "../engine/types.js";
2
+ /**
3
+ * The one place a scan result is handed to the protected-logic service. Not tier-gated, not behind
4
+ * --no-llm: confidence scoring and Zod-schema detection are Layer 1 capabilities, needed on every real
5
+ * scan. A failure degrades honestly (findings keep their safe local fallback and a warning is
6
+ * returned) rather than blocking the scan. Shared by `scan` and `check` so both make exactly the same
7
+ * calls under exactly the same conditions.
8
+ */
9
+ export declare function resolveScanProtectedLogic(result: ScanResult): Promise<{
10
+ findings: Finding[];
11
+ warning: string | null;
12
+ }>;
@@ -0,0 +1,16 @@
1
+ import { resolveProtectedLogic } from "../cloud/resolveProtectedLogic.js";
2
+ import { resolveProtectedLogicEndpoint } from "../cloud/config.js";
3
+ /**
4
+ * The one place a scan result is handed to the protected-logic service. Not tier-gated, not behind
5
+ * --no-llm: confidence scoring and Zod-schema detection are Layer 1 capabilities, needed on every real
6
+ * scan. A failure degrades honestly (findings keep their safe local fallback and a warning is
7
+ * returned) rather than blocking the scan. Shared by `scan` and `check` so both make exactly the same
8
+ * calls under exactly the same conditions.
9
+ */
10
+ export async function resolveScanProtectedLogic(result) {
11
+ const hasScorableFindings = result.findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0 || result.oversizedZodFiles.length > 0;
12
+ if (!hasScorableFindings)
13
+ return { findings: result.findings, warning: null };
14
+ const resolved = await resolveProtectedLogic(result.findings, result.pendingZodCandidates, resolveProtectedLogicEndpoint(), result.oversizedZodFiles);
15
+ return { findings: resolved.findings, warning: resolved.warning };
16
+ }
@@ -2,5 +2,10 @@ export interface ScanOptions {
2
2
  json: boolean;
3
3
  out: string;
4
4
  llm: boolean;
5
+ /** Write the drift baseline to this path (relative to root). With this set the scan uses the shared
6
+ * git-aware discovery mode (see gitDiscovery.ts) for the policy as well, so the two always agree. */
7
+ writeInventory?: string;
8
+ /** Write the baseline even if the scan completed with warnings that make it untrustworthy. */
9
+ allowIncompleteInventory?: boolean;
5
10
  }
6
11
  export declare function runScan(root: string, options: ScanOptions): Promise<void>;
@@ -6,8 +6,10 @@ import { formatText } from "../report/formatText.js";
6
6
  import { generatePolicyMarkdown } from "../policy/generatePolicy.js";
7
7
  import { resolveProviderForTier } from "../llm/resolveProviderForTier.js";
8
8
  import { resolveAmbiguousFindings } from "../llm/resolveAmbiguousFindings.js";
9
- import { resolveProtectedLogic } from "../cloud/resolveProtectedLogic.js";
10
- import { resolveProtectedLogicEndpoint } from "../cloud/config.js";
9
+ import { resolveScanProtectedLogic } from "./protectedLogicStep.js";
10
+ import { selectDiscovery } from "../engine/gitDiscovery.js";
11
+ import { computeInventory, inventoryBlockers, serializeInventory } from "../inventory/inventory.js";
12
+ import { readPackageVersion } from "../version.js";
11
13
  import { MAX_FRAGMENT_BYTES } from "../rules/zodPreFilter.js";
12
14
  import { SCAN_DISCLAIMER_REMINDER, byokNetworkNotice } from "../policy/legalNotices.js";
13
15
  export async function runScan(root, options) {
@@ -22,7 +24,8 @@ export async function runScan(root, options) {
22
24
  console.error(`No ${CONFIG_FILENAME} found. Run "srclawyer init" first.\n`);
23
25
  }
24
26
  }
25
- const result = await scan(root);
27
+ const discovery = options.writeInventory ? selectDiscovery(root) : null;
28
+ const result = await scan(root, { onlyFiles: discovery?.files ?? null });
26
29
  let findings = result.findings;
27
30
  if (result.workspaceWarning)
28
31
  console.error(result.workspaceWarning);
@@ -33,20 +36,11 @@ export async function runScan(root, options) {
33
36
  if (result.maxZodFragmentBytes > 0) {
34
37
  console.error(`Largest Zod-schema fragment this scan: ${result.maxZodFragmentBytes} bytes (hard ceiling: ${MAX_FRAGMENT_BYTES} bytes).`);
35
38
  }
36
- // Not tier-gated, not behind --no-llm: confidence scoring and Zod-schema detection are Layer 1
37
- // capabilities now, needed on every real scan regardless of which LLM tier (if any) is configured.
38
- // A failure here degrades honestly (findings keep their safe local fallback) rather than blocking
39
- // the scan — see resolveProtectedLogic's own per-call error handling.
40
- const hasScorableFindings = findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0 || result.oversizedZodFiles.length > 0;
41
- let protectedLogicWarning = null;
42
- if (hasScorableFindings) {
43
- const endpoint = resolveProtectedLogicEndpoint();
44
- const resolved = await resolveProtectedLogic(findings, result.pendingZodCandidates, endpoint, result.oversizedZodFiles);
45
- findings = resolved.findings;
46
- protectedLogicWarning = resolved.warning;
47
- if (protectedLogicWarning)
48
- console.error(protectedLogicWarning);
49
- }
39
+ const protectedLogic = await resolveScanProtectedLogic(result);
40
+ findings = protectedLogic.findings;
41
+ const protectedLogicWarning = protectedLogic.warning;
42
+ if (protectedLogicWarning)
43
+ console.error(protectedLogicWarning);
50
44
  let llmError = null;
51
45
  if (options.llm && config) {
52
46
  const { provider, error } = resolveProviderForTier(config);
@@ -60,7 +54,7 @@ export async function runScan(root, options) {
60
54
  if (config.tier === "byok" && config.llm) {
61
55
  console.log(byokNetworkNotice(config.llm.apiKeyEnvVar));
62
56
  }
63
- console.log(`Sending ${ambiguous.length} ambiguous item(s) to ${provider.name}...`);
57
+ console.log(`Sending ${ambiguous.length} ambiguous item(s) to ${provider.name} (an opaque id, category, description and one redacted source line each)...`);
64
58
  const resolved = await resolveAmbiguousFindings(ambiguous, provider, {
65
59
  // Preserves today's exact override behavior bit-for-bit: a missing/legacy key already
66
60
  // behaved as false before collectsChildrensData became nullable. Only the generated
@@ -77,14 +71,30 @@ export async function runScan(root, options) {
77
71
  const finalResult = { ...result, findings, ambiguousCount };
78
72
  const policyPath = resolve(root, options.out);
79
73
  writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning));
74
+ let inventoryNote = null;
75
+ if (options.writeInventory && discovery) {
76
+ const blockers = inventoryBlockers(result, protectedLogicWarning);
77
+ if (blockers.length > 0 && !options.allowIncompleteInventory) {
78
+ inventoryNote = `Inventory not written: this scan completed with warnings (see above), so its categories may be incomplete and would read as drift later. Fix them and re-run, or pass --allow-incomplete-inventory to write it anyway.`;
79
+ console.error(inventoryNote);
80
+ }
81
+ else {
82
+ const inventory = computeInventory(finalResult.findings, { srclawyerVersion: readPackageVersion(), discovery: discovery.mode });
83
+ writeFileSync(resolve(root, options.writeInventory), serializeInventory(inventory));
84
+ inventoryNote = `Wrote the drift baseline to ${options.writeInventory} (${inventory.categories.length} categories, ${inventory.processors.length} processors; ${discovery.mode} discovery). Commit it, then run "srclawyer check" in CI.`;
85
+ if (discovery.mode === "glob")
86
+ inventoryNote += ` (${discovery.reason}; the baseline records the ordinary filesystem walk.)`;
87
+ console.error(inventoryNote);
88
+ }
89
+ }
80
90
  if (result.unsupportedStackWarning || result.workspaceWarning || result.skippedFilesWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
81
91
  process.exitCode = 1;
82
92
  }
83
93
  if (options.json) {
84
- console.log(JSON.stringify({ ...finalResult, policyPath, llmError, protectedLogicWarning, legacyConfigWarning }, null, 2));
94
+ console.log(JSON.stringify({ ...finalResult, policyPath, llmError, protectedLogicWarning, legacyConfigWarning, inventoryNote }, null, 2));
85
95
  return;
86
96
  }
87
- console.log(formatText(finalResult));
97
+ console.log(formatText(finalResult, { aiReviewEnabled: config?.tier === "byok" }));
88
98
  if (protectedLogicWarning)
89
99
  console.log(`\n${protectedLogicWarning}`);
90
100
  if (legacyConfigWarning)
@@ -0,0 +1,20 @@
1
+ import type { DiscoveryMode } from "../inventory/inventory.js";
2
+ /**
3
+ * The shared discovery mode for the drift baseline (`scan --write-inventory`) and `check`. A baseline
4
+ * written on a laptop and checked on a CI runner must see the same files, but the ordinary walk also
5
+ * sees whatever untracked, gitignored output happens to be on disk (generated code, local scratch
6
+ * dirs) that a clean checkout never has -- which would show up as phantom drift. So inside a git
7
+ * repository both sides use `git ls-files --cached --others --exclude-standard` (tracked, plus
8
+ * untracked-but-not-ignored); anywhere else, the ordinary walk. `scan`'s own default discovery is not
9
+ * changed. The mode is recorded in the baseline, and a mismatch is reported as inconclusive.
10
+ */
11
+ export type DiscoverySelection = {
12
+ mode: "git";
13
+ files: Set<string>;
14
+ } | {
15
+ mode: "glob";
16
+ files: null;
17
+ reason: string;
18
+ };
19
+ export declare function selectDiscovery(root: string): DiscoverySelection;
20
+ export declare function discoveryMode(selection: DiscoverySelection): DiscoveryMode;
@@ -0,0 +1,31 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ function firstLine(text) {
4
+ return text.trim().split("\n")[0] ?? "";
5
+ }
6
+ export function selectDiscovery(root) {
7
+ const git = (args) => execFileSync("git", ["-C", root, ...args], { encoding: "buffer", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 512 * 1024 * 1024 });
8
+ try {
9
+ git(["rev-parse", "--is-inside-work-tree"]);
10
+ }
11
+ catch (error) {
12
+ const stderr = error.stderr?.toString() ?? "";
13
+ const reason = error.code === "ENOENT" ? "git is not installed" : stderr.includes("not a git repository") ? "not a git repository" : `git is not usable here (${firstLine(stderr) || "unknown error"})`;
14
+ return { mode: "glob", files: null, reason };
15
+ }
16
+ try {
17
+ const listing = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"]).toString("utf8");
18
+ const files = new Set();
19
+ for (const relative of listing.split("\0"))
20
+ if (relative)
21
+ files.add(resolve(root, relative));
22
+ return { mode: "git", files };
23
+ }
24
+ catch (error) {
25
+ const stderr = error.stderr?.toString() ?? "";
26
+ return { mode: "glob", files: null, reason: `git ls-files failed (${firstLine(stderr) || "unknown error"})` };
27
+ }
28
+ }
29
+ export function discoveryMode(selection) {
30
+ return selection.mode;
31
+ }
@@ -1,4 +1,5 @@
1
1
  import { glob } from "tinyglobby";
2
+ import { resolve } from "node:path";
2
3
  // expandDirectories: false keeps tinyglobby's matching identical to the fast-glob behavior this
3
4
  // replaced (a pattern matches only what it names, never everything under a matched directory).
4
5
  // tinyglobby/picomatch compiles braces to regex alternation rather than expanding them, so it has no
@@ -8,5 +9,8 @@ import { glob } from "tinyglobby";
8
9
  // traversal order, which neither fast-glob nor tinyglobby guarantees.
9
10
  export async function globFiles(patterns, options) {
10
11
  const { boundary, ...globOptions } = options;
11
- return (await glob(patterns, { expandDirectories: false, ...globOptions, ...(boundary ? { fs: boundary.globFs() } : {}) })).sort();
12
+ const found = (await glob(patterns, { expandDirectories: false, ...globOptions, ...(boundary ? { fs: boundary.globFs() } : {}) })).sort();
13
+ // An optional discovery restriction (git mode) is an intersection with the normal glob, so every
14
+ // ignore pattern and the symlink boundary above still apply unchanged.
15
+ return boundary ? found.filter((p) => boundary.keepsInDiscovery(resolve(options.cwd, p))) : found;
12
16
  }
@@ -10,7 +10,10 @@ export declare class RootBoundary {
10
10
  readonly realRoot: string;
11
11
  private readonly blocked;
12
12
  private readonly resolved;
13
+ private discoveryFilter;
13
14
  constructor(root: string);
15
+ restrictDiscoveryTo(files: Set<string> | null): void;
16
+ keepsInDiscovery(absolutePath: string): boolean;
14
17
  isInsideRealPath(resolved: string): boolean;
15
18
  /** Records a blocked link by its in-repo (lexical) path only -- never its target. */
16
19
  private recordBlocked;
@@ -17,6 +17,9 @@ export class RootBoundary {
17
17
  // through an in-root directory loop is the same link, counted once.
18
18
  blocked = new Map();
19
19
  resolved = new Map();
20
+ // When set, discovery only keeps files in this set (absolute paths) -- see gitDiscovery.ts. It lives
21
+ // here only because this object is already threaded through every glob call.
22
+ discoveryFilter = null;
20
23
  constructor(root) {
21
24
  this.root = root;
22
25
  try {
@@ -26,6 +29,12 @@ export class RootBoundary {
26
29
  this.realRoot = resolve(root);
27
30
  }
28
31
  }
32
+ restrictDiscoveryTo(files) {
33
+ this.discoveryFilter = files;
34
+ }
35
+ keepsInDiscovery(absolutePath) {
36
+ return this.discoveryFilter === null || this.discoveryFilter.has(absolutePath);
37
+ }
29
38
  isInsideRealPath(resolved) {
30
39
  return resolved === this.realRoot || resolved.startsWith(this.realRoot + sep);
31
40
  }
@@ -8,4 +8,8 @@ import type { ScanResult } from "./types.js";
8
8
  * rather than folded into it. Anyone auditing "does this touch the network" can still answer that by
9
9
  * reading this file alone.
10
10
  */
11
- export declare function scan(root: string): Promise<ScanResult>;
11
+ export interface ScanEngineOptions {
12
+ /** Absolute paths discovery is restricted to (git mode, for the drift baseline); unset = ordinary discovery. */
13
+ onlyFiles?: Set<string> | null;
14
+ }
15
+ export declare function scan(root: string, options?: ScanEngineOptions): Promise<ScanResult>;
@@ -103,17 +103,9 @@ async function runPythonCodeRules(filePath, source, root) {
103
103
  }
104
104
  return { findings, failure };
105
105
  }
106
- /**
107
- * Pure, local, offline — no network access anywhere in this function or anything it calls. Zod
108
- * schemas and confidence scoring can no longer be fully resolved here (see pendingZodCandidates and
109
- * every Finding.confidenceFactors) — resolving those against the protected-logic service is a
110
- * separate, explicit step in src/cloud/resolveProtectedLogic.ts, called from commands/scan.ts, the
111
- * same way Layer 2's resolveAmbiguousFindings.ts is a separate step layered on top of this function
112
- * rather than folded into it. Anyone auditing "does this touch the network" can still answer that by
113
- * reading this file alone.
114
- */
115
- export async function scan(root) {
106
+ export async function scan(root, options = {}) {
116
107
  const boundary = new RootBoundary(root);
108
+ boundary.restrictDiscoveryTo(options.onlyFiles ?? null);
117
109
  const [{ projects, workspaceWarning }, unsupportedStackWarning] = await Promise.all([
118
110
  discoverProjects(root, boundary),
119
111
  detectUnsupportedStack(root, boundary),
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Process exit codes -- one table for both commands so they can't drift apart. Documented in the README.
3
+ *
4
+ * code | srclawyer scan | srclawyer check
5
+ * -----+---------------------------------+----------------------------------------------------------
6
+ * 0 | clean | no drift
7
+ * 1 | completed with warnings | drift
8
+ * 2 | crash or bad usage | crash, bad usage, or a missing / invalid baseline
9
+ * 3 | (not used) | inconclusive: a warning that makes `scan` exit 1, or a
10
+ * | | discovery-mode mismatch -- never reported as drift
11
+ *
12
+ * `scan`'s "warnings" (1) and `check`'s "inconclusive" (3) are the same condition under different
13
+ * numbers; `scan`'s was left alone so existing scripts keep working.
14
+ */
15
+ export declare const EXIT_OK = 0;
16
+ export declare const EXIT_SCAN_WARNINGS = 1;
17
+ export declare const EXIT_DRIFT = 1;
18
+ export declare const EXIT_CRASH = 2;
19
+ export declare const EXIT_INCONCLUSIVE = 3;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Process exit codes -- one table for both commands so they can't drift apart. Documented in the README.
3
+ *
4
+ * code | srclawyer scan | srclawyer check
5
+ * -----+---------------------------------+----------------------------------------------------------
6
+ * 0 | clean | no drift
7
+ * 1 | completed with warnings | drift
8
+ * 2 | crash or bad usage | crash, bad usage, or a missing / invalid baseline
9
+ * 3 | (not used) | inconclusive: a warning that makes `scan` exit 1, or a
10
+ * | | discovery-mode mismatch -- never reported as drift
11
+ *
12
+ * `scan`'s "warnings" (1) and `check`'s "inconclusive" (3) are the same condition under different
13
+ * numbers; `scan`'s was left alone so existing scripts keep working.
14
+ */
15
+ export const EXIT_OK = 0;
16
+ export const EXIT_SCAN_WARNINGS = 1;
17
+ export const EXIT_DRIFT = 1;
18
+ export const EXIT_CRASH = 2;
19
+ export const EXIT_INCONCLUSIVE = 3;
@@ -0,0 +1,54 @@
1
+ import type { Finding, ScanResult } from "../engine/types.js";
2
+ /**
3
+ * The drift baseline: WHICH data categories and third-party processors the code touches, and nothing
4
+ * else -- no file names, line numbers, field names or timestamps. Users hand-edit their generated
5
+ * policy, so drift is judged against this, never against policy text.
6
+ *
7
+ * Built from Layer 1 only (finding.dataCategories / finding.processor) over ALL findings: AI-assisted
8
+ * review never changes it (so it's identical on every tier), and a finding moving between "confident"
9
+ * and "needs review" is not drift.
10
+ */
11
+ export declare const INVENTORY_SCHEMA = 1;
12
+ export declare const DEFAULT_INVENTORY_PATH = ".srclawyer.inventory.json";
13
+ /** "git": git ls-files --cached --others --exclude-standard; "glob": the ordinary filesystem walk. */
14
+ export type DiscoveryMode = "git" | "glob";
15
+ export interface Inventory {
16
+ schema: number;
17
+ /** Informational only -- never compared. Lets a drift report say "the baseline was written by an older version". */
18
+ srclawyerVersion: string;
19
+ discovery: DiscoveryMode;
20
+ categories: string[];
21
+ processors: string[];
22
+ }
23
+ export declare function computeInventory(findings: Finding[], meta: {
24
+ srclawyerVersion: string;
25
+ discovery: DiscoveryMode;
26
+ }): Inventory;
27
+ /** Fixed key order, two-space indent, trailing newline: the same inventory is always the same bytes. */
28
+ export declare function serializeInventory(inventory: Inventory): string;
29
+ export type ParsedInventory = {
30
+ ok: true;
31
+ inventory: Inventory;
32
+ } | {
33
+ ok: false;
34
+ reason: string;
35
+ };
36
+ export declare function parseInventory(text: string): ParsedInventory;
37
+ export interface InventoryDiff {
38
+ added: {
39
+ categories: string[];
40
+ processors: string[];
41
+ };
42
+ removed: {
43
+ categories: string[];
44
+ processors: string[];
45
+ };
46
+ }
47
+ export declare function diffInventories(baseline: Inventory, current: Inventory): InventoryDiff;
48
+ export declare function hasDrift(diff: InventoryDiff): boolean;
49
+ /**
50
+ * The warnings that make a drift verdict untrustworthy -- exactly the ones that make `scan` exit 1,
51
+ * minus the two that can't affect Layer 1 categories (an AI-review misconfiguration, and the legacy
52
+ * config filename). A degraded scan is missing findings, which would read as "removed" categories.
53
+ */
54
+ export declare function inventoryBlockers(result: ScanResult, protectedLogicWarning: string | null): string[];
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The drift baseline: WHICH data categories and third-party processors the code touches, and nothing
3
+ * else -- no file names, line numbers, field names or timestamps. Users hand-edit their generated
4
+ * policy, so drift is judged against this, never against policy text.
5
+ *
6
+ * Built from Layer 1 only (finding.dataCategories / finding.processor) over ALL findings: AI-assisted
7
+ * review never changes it (so it's identical on every tier), and a finding moving between "confident"
8
+ * and "needs review" is not drift.
9
+ */
10
+ export const INVENTORY_SCHEMA = 1;
11
+ export const DEFAULT_INVENTORY_PATH = ".srclawyer.inventory.json";
12
+ // Default code-point sort, deliberately not localeCompare: locale-aware ordering differs between
13
+ // machines (ICU data, LANG), which would make the committed file differ byte-for-byte between a laptop and CI.
14
+ function sortedUnique(values) {
15
+ return [...new Set(values)].sort();
16
+ }
17
+ export function computeInventory(findings, meta) {
18
+ return {
19
+ schema: INVENTORY_SCHEMA,
20
+ srclawyerVersion: meta.srclawyerVersion,
21
+ discovery: meta.discovery,
22
+ categories: sortedUnique(findings.flatMap((f) => f.dataCategories)),
23
+ processors: sortedUnique(findings.flatMap((f) => (f.processor ? [f.processor] : []))),
24
+ };
25
+ }
26
+ /** Fixed key order, two-space indent, trailing newline: the same inventory is always the same bytes. */
27
+ export function serializeInventory(inventory) {
28
+ const ordered = {
29
+ schema: inventory.schema,
30
+ srclawyerVersion: inventory.srclawyerVersion,
31
+ discovery: inventory.discovery,
32
+ categories: sortedUnique(inventory.categories),
33
+ processors: sortedUnique(inventory.processors),
34
+ };
35
+ return JSON.stringify(ordered, null, 2) + "\n";
36
+ }
37
+ export function parseInventory(text) {
38
+ let raw;
39
+ try {
40
+ raw = JSON.parse(text.replace(/^/, ""));
41
+ }
42
+ catch {
43
+ return { ok: false, reason: "it is not valid JSON" };
44
+ }
45
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
46
+ return { ok: false, reason: "it is not a JSON object" };
47
+ const o = raw;
48
+ if (o.schema !== INVENTORY_SCHEMA)
49
+ return { ok: false, reason: `unsupported schema ${JSON.stringify(o.schema)} (this version reads schema ${INVENTORY_SCHEMA})` };
50
+ if (o.discovery !== "git" && o.discovery !== "glob")
51
+ return { ok: false, reason: '"discovery" must be "git" or "glob"' };
52
+ const stringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
53
+ if (!stringArray(o.categories))
54
+ return { ok: false, reason: '"categories" must be an array of strings' };
55
+ if (!stringArray(o.processors))
56
+ return { ok: false, reason: '"processors" must be an array of strings' };
57
+ return {
58
+ ok: true,
59
+ inventory: {
60
+ schema: INVENTORY_SCHEMA,
61
+ srclawyerVersion: typeof o.srclawyerVersion === "string" ? o.srclawyerVersion : "unknown",
62
+ discovery: o.discovery,
63
+ categories: sortedUnique(o.categories),
64
+ processors: sortedUnique(o.processors),
65
+ },
66
+ };
67
+ }
68
+ function difference(from, minus) {
69
+ const exclude = new Set(minus);
70
+ return from.filter((v) => !exclude.has(v));
71
+ }
72
+ export function diffInventories(baseline, current) {
73
+ return {
74
+ added: {
75
+ categories: difference(current.categories, baseline.categories),
76
+ processors: difference(current.processors, baseline.processors),
77
+ },
78
+ removed: {
79
+ categories: difference(baseline.categories, current.categories),
80
+ processors: difference(baseline.processors, current.processors),
81
+ },
82
+ };
83
+ }
84
+ export function hasDrift(diff) {
85
+ return Object.values(diff.added).some((v) => v.length > 0) || Object.values(diff.removed).some((v) => v.length > 0);
86
+ }
87
+ /**
88
+ * The warnings that make a drift verdict untrustworthy -- exactly the ones that make `scan` exit 1,
89
+ * minus the two that can't affect Layer 1 categories (an AI-review misconfiguration, and the legacy
90
+ * config filename). A degraded scan is missing findings, which would read as "removed" categories.
91
+ */
92
+ export function inventoryBlockers(result, protectedLogicWarning) {
93
+ return [result.unsupportedStackWarning, result.workspaceWarning, result.skippedFilesWarning, protectedLogicWarning].filter((w) => !!w);
94
+ }
@@ -6,5 +6,9 @@ import type { LlmRequestItem } from "./types.js";
6
6
  * they leave this function — LlmRequestItem has no raw-evidence field, so a
7
7
  * provider implementation is structurally incapable of receiving
8
8
  * unredacted content.
9
+ *
10
+ * `requestId` is an opaque per-scan identifier (f0, f1, ...), never finding.id: finding ids embed the
11
+ * file path, line number and field name, and none of those may reach the provider. The caller
12
+ * (resolveAmbiguousFindings) owns the mapping back to findings and never sends it anywhere.
9
13
  */
10
- export declare function buildRequestItem(finding: Finding, collectsChildrensData: boolean): LlmRequestItem;
14
+ export declare function buildRequestItem(finding: Finding, collectsChildrensData: boolean, requestId: string): LlmRequestItem;
@@ -5,10 +5,14 @@ import { redactSecrets } from "../engine/secretRedaction.js";
5
5
  * they leave this function — LlmRequestItem has no raw-evidence field, so a
6
6
  * provider implementation is structurally incapable of receiving
7
7
  * unredacted content.
8
+ *
9
+ * `requestId` is an opaque per-scan identifier (f0, f1, ...), never finding.id: finding ids embed the
10
+ * file path, line number and field name, and none of those may reach the provider. The caller
11
+ * (resolveAmbiguousFindings) owns the mapping back to findings and never sends it anywhere.
8
12
  */
9
- export function buildRequestItem(finding, collectsChildrensData) {
13
+ export function buildRequestItem(finding, collectsChildrensData, requestId) {
10
14
  return {
11
- findingId: finding.id,
15
+ findingId: requestId,
12
16
  redactedEvidence: redactSecrets(finding.evidence),
13
17
  redactedDescription: redactSecrets(finding.description),
14
18
  dataCategories: finding.dataCategories,
@@ -6,8 +6,11 @@ const DEFAULT_BATCH_CHAR_BUDGET = 6000;
6
6
  export async function resolveAmbiguousFindings(findings, provider, options) {
7
7
  const batches = chunkFindings(findings, options.batchSize ?? DEFAULT_BATCH_SIZE);
8
8
  const resolvedById = new Map();
9
+ // Opaque per-scan ids (f0, f1, ...), unique across all batches. Finding ids embed file paths and
10
+ // line numbers, so only these ever go on the wire; the mapping back lives here and nowhere else.
11
+ const requestIdOf = new Map(findings.map((finding, i) => [finding, `f${i}`]));
9
12
  for (const batch of batches) {
10
- const items = batch.map((finding) => buildRequestItem(finding, options.collectsChildrensData));
13
+ const items = batch.map((finding) => buildRequestItem(finding, options.collectsChildrensData, requestIdOf.get(finding)));
11
14
  const isHighStakesById = new Map(items.map((item) => [item.findingId, item.isHighStakes]));
12
15
  let responses;
13
16
  try {
@@ -27,8 +30,9 @@ export async function resolveAmbiguousFindings(findings, provider, options) {
27
30
  }
28
31
  const responseById = new Map(responses.map((response) => [response.findingId, response]));
29
32
  for (const finding of batch) {
30
- const response = responseById.get(finding.id);
31
- const isHighStakes = isHighStakesById.get(finding.id) ?? false;
33
+ const requestId = requestIdOf.get(finding);
34
+ const response = responseById.get(requestId);
35
+ const isHighStakes = isHighStakesById.get(requestId) ?? false;
32
36
  resolvedById.set(finding.id, toResolution(response, isHighStakes, provider));
33
37
  }
34
38
  }
@@ -10,7 +10,7 @@ export function resolveProviderForTier(config) {
10
10
  if (!envVar) {
11
11
  return {
12
12
  provider: null,
13
- error: `Tier is "byok" but no API key environment variable is configured in ${CONFIG_FILENAME}. Run "srclawyer init" to set one.`,
13
+ error: `Tier is "byok" but no API key environment variable is configured in ${CONFIG_FILENAME}. Add "llm: { provider: anthropic, apiKeyEnvVar: <ENV_VAR_NAME> }" to it, or delete the file and re-run "srclawyer init --enable-ai-review".`,
14
14
  };
15
15
  }
16
16
  const apiKey = process.env[envVar];
@@ -8,16 +8,20 @@
8
8
  // notice and disclaimer, not a substitute for either.
9
9
  export const PRIVACY_POLICY_URL = "https://github.com/SrcLawyer/srclawyer#legal";
10
10
  export const TERMS_URL = "https://github.com/SrcLawyer/srclawyer#legal";
11
- // Verbatim from README.md's own "Network calls" section (minus markdown backticks, since this prints
12
- // to a terminal) -- one wording for this claim, not a second paraphrase that can drift from the first.
11
+ // Mirrors README.md's own "Network calls" section (condensed, minus markdown backticks since this
12
+ // prints to a terminal) -- the same claims in the same terms, not a second paraphrase that can drift.
13
+ // What is actually on the wire is pinned by tests/requestBodiesNoPaths.test.ts.
13
14
  export const NETWORK_CALLS_NOTICE = [
14
15
  "Heads up: SrcLawyer makes network calls.",
15
16
  "",
16
17
  "Detection itself is fully local and offline — no code leaves your machine for pattern matching.",
17
- "Once detection completes, scan makes one batched network call to grade the confidence of what it",
18
- "found (field names and derived yes/no signals only, never source code), and, for scans that use Zod",
19
- "schemas as request validators, one additional call sending a small redacted code fragment. This runs",
20
- "on every scan regardless of tier.",
18
+ "Once detection completes, every scan makes one batched call to grade the confidence of what it",
19
+ "found. For each field it sends an opaque id (f0, f1, ...), the data category the field's name maps",
20
+ "to, and three derived signals (is the name ambiguous, was it read directly from the request, how",
21
+ "many similar fields sit alongside it) -- no field names, file paths, line numbers or source code.",
22
+ "Scans that use Zod schemas as request validators make one additional batched call, sending an",
23
+ "opaque id and a small secret-redacted code fragment per schema (the schema, its validation call",
24
+ "and the imports it uses) -- never a file path or line number.",
21
25
  "",
22
26
  `Legal notice and disclaimer: ${PRIVACY_POLICY_URL}`,
23
27
  "(A dedicated privacy policy and terms of service are coming; this points to what exists today.)",
@@ -25,5 +29,5 @@ export const NETWORK_CALLS_NOTICE = [
25
29
  export const SCAN_DISCLAIMER_REMINDER = "Reminder: this is automated analysis, not legal advice — review the generated policy before publishing it.";
26
30
  export function byokNetworkNotice(apiKeyEnvVar) {
27
31
  const keySource = apiKeyEnvVar ? `env var ${apiKeyEnvVar}` : "your configured key";
28
- return `BYOK is configured: redacted code snippets for ambiguous findings will be sent to Anthropic under your own API key (${keySource}), for AI-assisted classification only.`;
32
+ return `BYOK is configured: for each finding scan couldn't classify, it will send Anthropic, under your own API key (${keySource}), an opaque id, the finding's data category and description (which names the field), and the one source line it was found on with secrets redacted — not the file's path or line number, and no other code — for AI-assisted classification only.`;
29
33
  }
@@ -1,2 +1,7 @@
1
1
  import type { ScanResult } from "../engine/types.js";
2
- export declare function formatText(result: ScanResult): string;
2
+ export interface FormatTextOptions {
3
+ /** True when the config has AI-assisted review (tier: byok) turned on -- the hint about that
4
+ * advanced option is only relevant when it isn't. */
5
+ aiReviewEnabled?: boolean;
6
+ }
7
+ export declare function formatText(result: ScanResult, options?: FormatTextOptions): string;
@@ -1,5 +1,5 @@
1
1
  const BANNER_RULE = "=".repeat(72);
2
- export function formatText(result) {
2
+ export function formatText(result, options = {}) {
3
3
  const lines = [];
4
4
  if (result.unsupportedStackWarning) {
5
5
  lines.push(BANNER_RULE);
@@ -54,7 +54,12 @@ export function formatText(result) {
54
54
  }
55
55
  else if (result.ambiguousCount > 0) {
56
56
  lines.push("");
57
- lines.push(`${result.ambiguousCount} item(s) require deeper analysis before they can be included in a policy. Run "srclawyer scan" with the "byok" tier configured (bring your own Anthropic key) to attempt AI-assisted resolution, or resolve manually. A "managed" tier is planned but not yet available.`);
57
+ const base = `${result.ambiguousCount} item(s) require deeper analysis before they can be included in a policy; review them manually.`;
58
+ // Only worth mentioning when the advanced option isn't already on, and only as a pointer -- it is
59
+ // opt-in (init --enable-ai-review, or a hand-edited config), not something to recommend to everyone.
60
+ lines.push(options.aiReviewEnabled
61
+ ? base
62
+ : `${base} (Optional, advanced: AI-assisted resolution with your own Anthropic API key — see "AI-assisted review" in the README.)`);
58
63
  }
59
64
  return lines.join("\n");
60
65
  }
@@ -0,0 +1 @@
1
+ export declare function readPackageVersion(): string;
@@ -0,0 +1,9 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ // Read from package.json rather than hardcoded, so no reported version can drift from the published
5
+ // one. Works from both src/ (tsx, tests) and dist/: this file sits one level below package.json either way.
6
+ export function readPackageVersion() {
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ return JSON.parse(readFileSync(join(here, "../package.json"), "utf8")).version;
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srclawyer",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Static analysis engine that derives privacy policy documentation directly from source code",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",