srclawyer 0.5.0 → 0.6.1

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.
Files changed (35) hide show
  1. package/README.md +28 -6
  2. package/dist/cli.js +45 -3
  3. package/dist/cloud/protectedLogicClient.d.ts +3 -0
  4. package/dist/cloud/protectedLogicClient.js +62 -20
  5. package/dist/cloud/resolveProtectedLogic.d.ts +1 -1
  6. package/dist/cloud/resolveProtectedLogic.js +15 -1
  7. package/dist/commands/init.d.ts +16 -1
  8. package/dist/commands/init.js +107 -15
  9. package/dist/commands/scan.js +19 -4
  10. package/dist/config/config.d.ts +12 -1
  11. package/dist/config/config.js +12 -1
  12. package/dist/engine/discoverFiles.d.ts +1 -0
  13. package/dist/engine/discoverFiles.js +16 -2
  14. package/dist/engine/languageDetection.js +37 -7
  15. package/dist/engine/pythonAstUtils.d.ts +2 -0
  16. package/dist/engine/pythonAstUtils.js +44 -0
  17. package/dist/engine/scanEngine.js +42 -5
  18. package/dist/engine/types.d.ts +9 -0
  19. package/dist/grammars/LICENSE +21 -0
  20. package/dist/grammars/tree-sitter-python.wasm +0 -0
  21. package/dist/policy/generatePolicy.d.ts +1 -1
  22. package/dist/policy/generatePolicy.js +127 -3
  23. package/dist/policy/legalNotices.d.ts +5 -0
  24. package/dist/policy/legalNotices.js +29 -0
  25. package/dist/report/formatText.js +1 -1
  26. package/dist/rules/dataCategoryLexicon.js +9 -3
  27. package/dist/rules/frameworkRules.js +89 -12
  28. package/dist/rules/pythonFrameworkRules.d.ts +3 -0
  29. package/dist/rules/pythonFrameworkRules.js +136 -0
  30. package/dist/rules/pythonSdkRules.d.ts +3 -0
  31. package/dist/rules/pythonSdkRules.js +73 -0
  32. package/dist/rules/safeFieldNames.js +14 -0
  33. package/dist/rules/zodPreFilter.d.ts +21 -1
  34. package/dist/rules/zodPreFilter.js +104 -13
  35. package/package.json +3 -2
package/README.md CHANGED
@@ -2,9 +2,9 @@
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
- > This is a Phase 1 scaffold: the CLI (`init`, `scan`), the Layer 1 static analysis engine — SDK/framework pattern detection, HTML/browser-API detection, and OpenAPI/GraphQL schema parsing — LLM-assisted reasoning for ambiguous findings (Layer 2, bring-your-own-key or managed), and markdown privacy policy generation. CI/CD integration is not yet implemented.
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.
6
6
  >
7
- > Scanning is manual-only (`srclawyer scan`, run on demand) — this is the free/solo tier's permanent behavior, not a placeholder. CI/CD integration (PR-triggered scans, a weekly backstop scan) is scoped for the paid/team tier only; see `PRODUCT_SPEC.md` Section 8.5.
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
 
9
9
  ## Usage
10
10
 
@@ -63,18 +63,34 @@ If none of this resolves it, `npx srclawyer scan` (see [Usage](#usage) above) si
63
63
 
64
64
  ## What `scan` does today
65
65
 
66
- - **Known SDK integrations** (import/require detection): Stripe, SendGrid, Twilio, Google Analytics, Mixpanel, Auth0, Firebase, Cognito — each mapped to the data categories it typically processes (`src/rules/processorProfiles.ts`).
67
- - **Request-body field collection** in Express (`req.body.x`, destructured `req.body`) and Next.js (both `pages/api` and the app router's `await request.json()`), classified against a field-name lexicon (`src/rules/dataCategoryLexicon.ts`).
66
+ - **Known SDK integrations** (import/require detection): Stripe, SendGrid, Twilio, Google Analytics, Mixpanel, Auth0, Firebase, Cognito for JS/TS; Stripe, SendGrid, Twilio, Mixpanel, Auth0, Firebase for Python — each mapped to the data categories it typically processes (`src/rules/processorProfiles.ts`).
67
+ - **Request-body field collection** in Express (`req.body.x`, destructured `req.body`) and Next.js (both `pages/api` and the app router's `await request.json()`), and in **Flask** (`request.form`/`.json`/`.args`, both subscript and `.get(...)` forms), classified against a field-name lexicon (`src/rules/dataCategoryLexicon.ts`). In both languages, a value assigned to a local variable earlier in the *same function* (`const body = req.body` / `data = request.get_json()`) is tracked one mechanical hop, so a later `body.email` / `data['email']` is still caught — not just the direct, unaliased form.
68
68
  - **OpenAPI/Swagger schemas** (`components.schemas.*.properties`) and **GraphQL SDL** type/input fields.
69
69
  - **HTML forms and inline scripts** (`src/rules/htmlRules.ts`): native `<input>` elements classified by `type` (password/email/tel are unambiguous native signals) or by `id`/`name` against the lexicon; inline `<script>` blocks are extracted and run through the same SDK/framework/web-API rules as any other file.
70
70
  - **Browser platform APIs** (`src/rules/webApiRules.ts`): `getUserMedia` (split into microphone and camera access), geolocation, and `localStorage`/`sessionStorage` writes classified by key name.
71
71
  - Fields that can't be confidently classified (generic names like `data`, `payload`) are surfaced with `requiresReview: true` rather than silently guessed.
72
- - **A field-name allowlist** (`src/rules/safeFieldNames.ts`) suppresses findings entirely for exact-match structural fields (`id`, `createdAt`, `status`, pagination params, etc.) so "needs review" stays a signal, not noise from routine boilerplate. Anything not on the allowlist keeps the existing classify-or-flag behavior above.
73
- - **Unsupported language/framework detection** (`src/engine/languageDetection.ts`) runs at the start of every scan: it checks for Django (`manage.py`), Rails (`Gemfile` + `app/controllers`), gRPC (`.proto` files), and falls back to a file-count ratio for Python/Ruby/Go/Java/PHP/C# vs JS/TS/HTML. When it fires, a bordered warning appears at the top of the CLI output *and* at the top of the generated `PRIVACY_POLICY.md` (not just a log line easy to miss), the warning is included in `--json` output, and the process exits with code 1 so CI can't silently treat a scan of an unsupported codebase as a pass.
72
+ - **A field-name allowlist** (`src/rules/safeFieldNames.ts`) suppresses findings entirely for exact-match structural and pagination/sort fields (`id`, `createdAt`, `status`, `per_page`, `cursor`, `sort_by`, etc.) so "needs review" stays a signal, not noise from routine boilerplate. Anything not on the allowlist keeps the existing classify-or-flag behavior above.
73
+ - **Unsupported language/framework detection** (`src/engine/languageDetection.ts`) runs at the start of every scan: it checks for Django (`manage.py`), FastAPI (`from fastapi import` / `import fastapi`), Rails (`Gemfile` + `app/controllers`), gRPC (`.proto` files), and falls back to a file-count ratio for Ruby/Go/Java/PHP/C# vs JS/TS/HTML/Python. When it fires, a bordered warning appears at the top of the CLI output *and* at the top of the generated `PRIVACY_POLICY.md` (not just a log line easy to miss), the warning is included in `--json` output, and the process exits with code 1 so CI can't silently treat a scan of an unsupported codebase as a pass.
74
74
  - **Generates a markdown privacy policy** (`src/policy/generatePolicy.ts`) in the same command: findings are grouped by data category, each clause cites the file:line that justified it, third-party processors get their own section, and anything flagged `requiresReview` is excluded from the body and listed separately under "Items Requiring Manual Review" instead of being guessed. The mandatory disclaimer (`src/policy/disclaimer.ts`) is always appended, unconditionally.
75
75
 
76
76
  Every finding traces back to a file and line (`src/engine/types.ts#Finding`).
77
77
 
78
+ ### Supported languages and frameworks
79
+
80
+ | Language | Frameworks | Status |
81
+ |---|---|---|
82
+ | JavaScript / TypeScript | Express, Next.js (pages + app router) | Supported |
83
+ | Python | Flask | Supported (beta — see "Known gaps" below) |
84
+ | Python | FastAPI, Django | Explicitly detected and flagged as unsupported, never silently skipped |
85
+ | Ruby, Go, Java, PHP, C#, gRPC | — | Explicitly detected and flagged as unsupported |
86
+
87
+ ### Known gaps
88
+
89
+ - **Cross-function aliasing isn't tracked, in either language.** The same-function alias tracking described above follows exactly one assignment hop within the function it occurs in. If that value is passed on — as a function parameter, through a closure, via a module-level variable — a field read from it afterward is not detected. Example: a Flask handler reads `data = request.get_json()` and calls `User.from_dict(data)`; a field read as `data['password']` *inside* `from_dict` is not caught, only reads inside the original handler function are.
90
+ - **Data sent to a third party via a raw HTTP call isn't attributed to that processor.** SDK-integration detection (above) is anchored to known package imports. A call like Python's `requests.post("https://api.some-provider.com/...", json={...})` or JS's `fetch("https://api.some-provider.com/...")` — bypassing a dedicated SDK entirely — is invisible to this detection today, in both languages, regardless of how sensitive the data it sends is.
91
+ - **Python support is based on one real-world corpus repository so far** (a Flask app; see `test-fixtures/golden-corpus/`), not the broader validation JS detection has had. Expect rougher edges on Flask codebases with unusual patterns.
92
+ - **OpenAPI/Swagger scanning reports every schema under `components.schemas`, including response-only ones** — a schema that's only ever returned by the server (never submitted by a client) gets identical treatment to a real request-input schema, since nothing currently checks whether a schema is actually reachable from an operation's `requestBody` or `parameters`. On a large, schema-heavy spec this can mean a long "Items Requiring Manual Review" list that includes fields the product never collects, only ever returns. Separately, a schema defined **inline** inside a `requestBody` (not as a named, `$ref`'d `components.schemas` entry) isn't read at all today, in either direction — only named component schemas are scanned.
93
+
78
94
  ## Network calls
79
95
 
80
96
  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.
@@ -91,6 +107,12 @@ Detection itself is fully local and offline — no code leaves your machine for
91
107
 
92
108
  `npm test` never touches the network. A separate, real-world accuracy check lives in `test-fixtures/golden-corpus/` — see its own README for what it measures and why it's not part of the default test run.
93
109
 
110
+ ## Legal
111
+
112
+ SrcLawyer generates a *starting draft*, not a substitute for legal review — every generated policy carries that disclaimer (`src/policy/disclaimer.ts`), and `scan` repeats a short reminder of it at the end of its own output.
113
+
114
+ A dedicated privacy policy and terms of service **for SrcLawyer itself** (the tool, not the policy it writes for your project) don't exist yet. Until they do, this README is the authoritative reference for what this tool sends over the network and to whom — see [Network calls](#network-calls) above — and for how it's licensed — see [License](#license) below.
115
+
94
116
  ## License
95
117
 
96
118
  [Business Source License 1.1](LICENSE) (BUSL-1.1) — source-available, not OSI-approved open source.
package/dist/cli.js CHANGED
@@ -16,9 +16,51 @@ program
16
16
  .version(version);
17
17
  program
18
18
  .command("init")
19
- .description("One-time setup: jurisdiction, markets, industry")
20
- .action(async () => {
21
- await runInit(process.cwd());
19
+ .description("One-time setup: jurisdiction, markets, industry, and optional policy details (company info, data practices, regional rights)")
20
+ .option("--policy-details", "ask the optional policy-detail questions (company info, data practices, regional rights)")
21
+ .option("--no-policy-details", "skip all optional policy-detail questions, leaving them unanswered")
22
+ .option("--collects-childrens-data", "declare that this product knowingly collects data from children under 13/16")
23
+ .option("--no-collects-childrens-data", "declare that this product does not knowingly collect data from children under 13/16")
24
+ .option("--company-name <name>", "company or product name")
25
+ .option("--site-url <url>", "site or app URL")
26
+ .option("--privacy-contact-email <email>", "privacy contact email address")
27
+ .option("--eu-uk-users", "declare that this product has users in the EU/UK")
28
+ .option("--no-eu-uk-users", "declare that this product has no users in the EU/UK")
29
+ .option("--california-users", "declare that this product has users in California")
30
+ .option("--no-california-users", "declare that this product has no users in California")
31
+ .option("--sells-or-shares-data", "declare that personal data is sold or shared with third parties")
32
+ .option("--no-sells-or-shares-data", "declare that personal data is not sold or shared with third parties")
33
+ .option("--uses-data-for-advertising", "declare that personal data is used for advertising")
34
+ .option("--no-uses-data-for-advertising", "declare that personal data is not used for advertising")
35
+ .option("--data-retention-policy <text>", "how long personal data is retained")
36
+ .option("--account-deletion-instructions <text>", "how a user can delete their account or request data deletion")
37
+ .option("--minimum-age <number>", "minimum age required to use this product")
38
+ .option("--sends-data-to-ai-services", "declare that user data is sent to third-party AI services")
39
+ .option("--no-sends-data-to-ai-services", "declare that user data is not sent to third-party AI services")
40
+ .action(async (opts) => {
41
+ let minimumAge;
42
+ if (opts.minimumAge !== undefined) {
43
+ const parsed = Number(opts.minimumAge);
44
+ if (!Number.isInteger(parsed) || parsed < 0) {
45
+ program.error(`error: --minimum-age must be a whole, non-negative number (got "${opts.minimumAge}")`);
46
+ }
47
+ minimumAge = parsed;
48
+ }
49
+ await runInit(process.cwd(), {
50
+ policyDetails: opts.policyDetails,
51
+ collectsChildrensData: opts.collectsChildrensData,
52
+ companyName: opts.companyName,
53
+ siteUrl: opts.siteUrl,
54
+ privacyContactEmail: opts.privacyContactEmail,
55
+ euUkUsers: opts.euUkUsers,
56
+ californiaUsers: opts.californiaUsers,
57
+ sellsOrSharesData: opts.sellsOrSharesData,
58
+ usesDataForAdvertising: opts.usesDataForAdvertising,
59
+ dataRetentionPolicy: opts.dataRetentionPolicy,
60
+ accountDeletionInstructions: opts.accountDeletionInstructions,
61
+ minimumAge,
62
+ sendsDataToAiServices: opts.sendsDataToAiServices,
63
+ });
22
64
  });
23
65
  program
24
66
  .command("scan")
@@ -3,6 +3,9 @@ export interface ProtectedLogicClientOptions {
3
3
  baseUrl: string;
4
4
  apiKey: string;
5
5
  batchSize?: number;
6
+ /** Max total JSON-serialized bytes of items per batch, in addition to batchSize -- whichever limit
7
+ * is hit first ends the batch. */
8
+ batchMaxBytes?: number;
6
9
  timeoutMs?: number;
7
10
  }
8
11
  export interface ScoreFieldsItem {
@@ -3,42 +3,83 @@
3
3
  * these calls are bound by). Mirrors resolveAmbiguousFindings.ts's established pattern deliberately:
4
4
  * batch, try/catch per batch, never throw out of this module — a failure here degrades the scan
5
5
  * (safe local fallback + a scan-level warning), it never crashes it.
6
+ *
7
+ * Batching is by total request-body bytes as well as item count: confirmed via wrangler tail that the
8
+ * Worker's own CPU time limit is hit intermittently on large detect-zod-schema batches (large
9
+ * code fragments mean more server-side parsing work per batch, not just more JSON to transfer). Count
10
+ * alone (the original design) doesn't bound that — 25 large fragments can still be a very large batch.
6
11
  */
7
12
  const DEFAULT_BATCH_SIZE = 25;
13
+ const DEFAULT_BATCH_MAX_BYTES = 50_000;
8
14
  const DEFAULT_TIMEOUT_MS = 10_000;
9
- function chunk(items, size) {
15
+ const RETRY_BACKOFF_MS = 500;
16
+ /**
17
+ * Greedily fills batches up to maxCount items AND maxBytes of JSON-serialized item size, whichever
18
+ * comes first. A single item already at or over maxBytes (shouldn't happen for zod candidates — see
19
+ * zodPreFilter.ts's own per-fragment cap, enforced upstream — but score-fields items have no such cap)
20
+ * still gets its own one-item batch rather than being dropped, so this never silently discards data;
21
+ * only the per-fragment cap upstream does that, and it always warns when it does.
22
+ */
23
+ function chunkByCountAndBytes(items, maxCount, maxBytes) {
10
24
  const batches = [];
11
- for (let i = 0; i < items.length; i += size)
12
- batches.push(items.slice(i, i + size));
25
+ let current = [];
26
+ let currentBytes = 0;
27
+ for (const item of items) {
28
+ const itemBytes = Buffer.byteLength(JSON.stringify(item), "utf8");
29
+ const wouldExceed = current.length > 0 && (current.length >= maxCount || currentBytes + itemBytes > maxBytes);
30
+ if (wouldExceed) {
31
+ batches.push(current);
32
+ current = [];
33
+ currentBytes = 0;
34
+ }
35
+ current.push(item);
36
+ currentBytes += itemBytes;
37
+ }
38
+ if (current.length > 0)
39
+ batches.push(current);
13
40
  return batches;
14
41
  }
42
+ function sleep(ms) {
43
+ return new Promise((resolve) => setTimeout(resolve, ms));
44
+ }
15
45
  async function postJson(url, apiKey, body, timeoutMs) {
16
- const controller = new AbortController();
17
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
18
- try {
19
- const response = await fetch(url, {
20
- method: "POST",
21
- headers: { "content-type": "application/json", "x-api-key": apiKey },
22
- body: JSON.stringify(body),
23
- signal: controller.signal,
24
- });
25
- if (!response.ok) {
26
- throw new Error(`protected-logic request failed (${response.status})`);
46
+ const MAX_ATTEMPTS = 2; // one retry, on a 5xx only -- a 4xx (bad request, auth) won't succeed on retry
47
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
48
+ const controller = new AbortController();
49
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
50
+ let status;
51
+ try {
52
+ const response = await fetch(url, {
53
+ method: "POST",
54
+ headers: { "content-type": "application/json", "x-api-key": apiKey },
55
+ body: JSON.stringify(body),
56
+ signal: controller.signal,
57
+ });
58
+ if (response.ok)
59
+ return (await response.json());
60
+ status = response.status;
27
61
  }
28
- return (await response.json());
29
- }
30
- finally {
31
- clearTimeout(timeout);
62
+ finally {
63
+ clearTimeout(timeout);
64
+ }
65
+ const isLastAttempt = attempt === MAX_ATTEMPTS;
66
+ if (status < 500 || isLastAttempt) {
67
+ throw new Error(`protected-logic request failed (${status})`);
68
+ }
69
+ await sleep(RETRY_BACKOFF_MS);
32
70
  }
71
+ // Unreachable: the loop above always either returns or throws.
72
+ throw new Error("protected-logic request failed (unreachable)");
33
73
  }
34
74
  export async function scoreFields(items, options) {
35
75
  const results = new Map();
36
76
  if (items.length === 0)
37
77
  return { results, error: null };
38
78
  const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
79
+ const batchMaxBytes = options.batchMaxBytes ?? DEFAULT_BATCH_MAX_BYTES;
39
80
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
40
81
  let error = null;
41
- for (const batch of chunk(items, batchSize)) {
82
+ for (const batch of chunkByCountAndBytes(items, batchSize, batchMaxBytes)) {
42
83
  try {
43
84
  const response = await postJson(`${options.baseUrl}/v1/score-fields`, options.apiKey, { fields: batch }, timeoutMs);
44
85
  for (const result of response.results)
@@ -55,9 +96,10 @@ export async function detectZodSchemas(items, options) {
55
96
  if (items.length === 0)
56
97
  return { results, error: null };
57
98
  const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
99
+ const batchMaxBytes = options.batchMaxBytes ?? DEFAULT_BATCH_MAX_BYTES;
58
100
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
59
101
  let error = null;
60
- for (const batch of chunk(items, batchSize)) {
102
+ for (const batch of chunkByCountAndBytes(items, batchSize, batchMaxBytes)) {
61
103
  try {
62
104
  const response = await postJson(`${options.baseUrl}/v1/detect-zod-schema`, options.apiKey, { candidates: batch }, timeoutMs);
63
105
  for (const result of response.results)
@@ -11,4 +11,4 @@ export interface ResolveProtectedLogicResult {
11
11
  * same way resolveAmbiguousFindings.ts is layered on top of it for Layer 2 — not called from inside
12
12
  * scan() itself, so scan()'s own purity/auditability claim stays true.
13
13
  */
14
- export declare function resolveProtectedLogic(findings: Finding[], pendingZodCandidates: ZodCandidate[], options: ProtectedLogicClientOptions): Promise<ResolveProtectedLogicResult>;
14
+ export declare function resolveProtectedLogic(findings: Finding[], pendingZodCandidates: ZodCandidate[], options: ProtectedLogicClientOptions, oversizedZodFiles?: string[]): Promise<ResolveProtectedLogicResult>;
@@ -1,4 +1,5 @@
1
1
  import { classifyFieldName, isAmbiguousFieldName } from "../rules/dataCategoryLexicon.js";
2
+ import { isSafeFieldName } from "../rules/safeFieldNames.js";
2
3
  import { countConfidentSiblings } from "../rules/confidenceFactors.js";
3
4
  import { detectZodSchemas, scoreFields } from "./protectedLogicClient.js";
4
5
  /**
@@ -7,9 +8,20 @@ import { detectZodSchemas, scoreFields } from "./protectedLogicClient.js";
7
8
  * same way resolveAmbiguousFindings.ts is layered on top of it for Layer 2 — not called from inside
8
9
  * scan() itself, so scan()'s own purity/auditability claim stays true.
9
10
  */
10
- export async function resolveProtectedLogic(findings, pendingZodCandidates, options) {
11
+ export async function resolveProtectedLogic(findings, pendingZodCandidates, options, oversizedZodFiles = []) {
11
12
  let workingFindings = findings;
12
13
  const warnings = [];
14
+ if (oversizedZodFiles.length > 0) {
15
+ // Kept to a single summarized line -- this list is meant to be rare now that the hard ceiling is
16
+ // only for pathological cases (see MAX_FRAGMENT_BYTES), but a long list here would still be an
17
+ // unreadable wall of text. The full, untruncated list is always in ScanResult.oversizedZodFiles
18
+ // (surfaced in --json output), so nothing here is actually lost, just not all printed inline.
19
+ const PREVIEW_COUNT = 3;
20
+ const preview = oversizedZodFiles.slice(0, PREVIEW_COUNT).join(", ");
21
+ const remaining = oversizedZodFiles.length - PREVIEW_COUNT;
22
+ const suffix = remaining > 0 ? `, and ${remaining} more (full list in --json output)` : "";
23
+ warnings.push(`Zod-schema detection skipped for ${oversizedZodFiles.length} file(s) because the extracted fragment was too large to send safely: ${preview}${suffix}.`);
24
+ }
13
25
  if (pendingZodCandidates.length > 0) {
14
26
  const byId = new Map(pendingZodCandidates.map((c) => [c.id, c]));
15
27
  const { results, error } = await detectZodSchemas(pendingZodCandidates.map((c) => ({ id: c.id, codeFragment: c.codeFragment })), options);
@@ -22,6 +34,8 @@ export async function resolveProtectedLogic(findings, pendingZodCandidates, opti
22
34
  continue;
23
35
  const allNames = result.fields.map((f) => f.name);
24
36
  for (const field of result.fields) {
37
+ if (isSafeFieldName(field.name))
38
+ continue;
25
39
  const sourceLine = candidate.fragmentLineToSourceLine[field.line - 1] ?? field.line;
26
40
  const lexiconCategory = classifyFieldName(field.name);
27
41
  const ambiguousName = isAmbiguousFieldName(field.name);
@@ -1 +1,16 @@
1
- export declare function runInit(root: string): Promise<void>;
1
+ export interface InitFlags {
2
+ policyDetails?: boolean;
3
+ collectsChildrensData?: boolean;
4
+ companyName?: string;
5
+ siteUrl?: string;
6
+ privacyContactEmail?: string;
7
+ euUkUsers?: boolean;
8
+ californiaUsers?: boolean;
9
+ sellsOrSharesData?: boolean;
10
+ usesDataForAdvertising?: boolean;
11
+ dataRetentionPolicy?: string;
12
+ accountDeletionInstructions?: string;
13
+ minimumAge?: number;
14
+ sendsDataToAiServices?: boolean;
15
+ }
16
+ export declare function runInit(root: string, flags?: InitFlags): Promise<void>;
@@ -2,6 +2,7 @@ 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
6
  function guessIndustry(root) {
6
7
  const pkgPath = join(root, "package.json");
7
8
  if (!existsSync(pkgPath))
@@ -23,7 +24,62 @@ async function ask(lines, question, fallback) {
23
24
  const answer = done ? "" : value.trim();
24
25
  return answer.length > 0 ? answer : fallback;
25
26
  }
26
- export async function runInit(root) {
27
+ async function askOptional(lines, question) {
28
+ process.stdout.write(`${question} (optional, press Enter to skip): `);
29
+ const { value, done } = await lines.next();
30
+ const answer = done ? "" : value.trim();
31
+ return answer.length > 0 ? answer : null;
32
+ }
33
+ // suggested, when not null, is shown as the bracketed default and adopted on a blank answer -- used
34
+ // to prefill EU/UK and California questions from targetMarkets without ever forcing an answer. When
35
+ // suggested is null (no signal), blank means genuinely unanswered, same as every other optional field.
36
+ async function askYesNoOptional(lines, question, suggested = null) {
37
+ const suffix = suggested === null ? " (y/n, optional, press Enter to skip)" : ` [${suggested ? "y" : "n"}]`;
38
+ process.stdout.write(`${question}${suffix}: `);
39
+ const { value, done } = await lines.next();
40
+ const answer = done ? "" : value.trim().toLowerCase();
41
+ if (answer.length === 0)
42
+ return suggested;
43
+ return /^y(es)?$/.test(answer);
44
+ }
45
+ async function askOptionalAge(lines, question) {
46
+ const raw = await askOptional(lines, question);
47
+ if (raw === null)
48
+ return null;
49
+ const parsed = Number(raw);
50
+ if (!Number.isInteger(parsed) || parsed < 0) {
51
+ console.log(` (Could not read "${raw}" as a whole, non-negative number -- skipping. You can set this later by editing ${CONFIG_FILENAME}.)`);
52
+ return null;
53
+ }
54
+ return parsed;
55
+ }
56
+ // Generic resolution order for every new optional field: an explicit flag always wins outright;
57
+ // otherwise the field is only asked if the policy-details gate resolved to yes; otherwise it's null.
58
+ async function resolveField(flagValue, gateYes, askFn) {
59
+ if (flagValue !== undefined)
60
+ return flagValue;
61
+ if (!gateYes)
62
+ return null;
63
+ return askFn();
64
+ }
65
+ const EU_UK_TOKENS = new Set(["eu", "uk", "gb", "eea", "european union", "united kingdom"]);
66
+ const CALIFORNIA_TOKENS = new Set(["ca", "california", "us-ca"]);
67
+ function targetMarketsSuggestEuUk(markets) {
68
+ return markets.some((m) => EU_UK_TOKENS.has(m.trim().toLowerCase()));
69
+ }
70
+ function targetMarketsSuggestCalifornia(markets) {
71
+ return markets.some((m) => CALIFORNIA_TOKENS.has(m.trim().toLowerCase()));
72
+ }
73
+ // Only warns in the one direction that's a real contradiction: target markets signal a region, but
74
+ // the final answer explicitly denies it. The reverse (no signal, but the user says yes) isn't a
75
+ // contradiction -- targetMarkets is free text and was never meant to be an exhaustive jurisdiction
76
+ // list. Never blocks or overrides -- the explicit answer always stands, this only warns.
77
+ function warnIfContradictsTargetMarkets(label, finalValue, suggestedFromMarkets) {
78
+ if (suggestedFromMarkets && finalValue === false) {
79
+ console.log(`\nWarning: your target markets suggest ${label} users, but you indicated none -- the generated policy will omit ${label}-specific disclosures. Re-run init or edit ${CONFIG_FILENAME} if this is incorrect.\n`);
80
+ }
81
+ }
82
+ export async function runInit(root, flags = {}) {
27
83
  const existingPath = configPath(root);
28
84
  if (existsSync(existingPath)) {
29
85
  console.log(`${CONFIG_FILENAME} already exists. Delete it first if you want to redo setup.`);
@@ -36,36 +92,72 @@ export async function runInit(root) {
36
92
  const guessedIndustry = guessIndustry(root);
37
93
  const rl = createInterface({ input: process.stdin, output: process.stdout });
38
94
  const lines = rl[Symbol.asyncIterator]();
95
+ // Printed once, here, not re-shown on every scan -- informational only, never blocks: nothing below
96
+ // waits on an acknowledgment, so this is safe in non-interactive contexts (CI, scripted installs).
97
+ console.log(`${NETWORK_CALLS_NOTICE}\n`);
39
98
  console.log("SrcLawyer setup — answered once, reused on every scan.\n");
40
99
  const entityLocation = await ask(lines, "Where is your legal entity located? (country)", DEFAULT_CONFIG.entityLocation ?? "");
41
100
  const targetMarketsRaw = await ask(lines, "Which regions/markets do you serve? (comma-separated, e.g. US, EU)", "");
101
+ const targetMarkets = targetMarketsRaw.split(",").map((s) => s.trim()).filter(Boolean);
42
102
  const industry = await ask(lines, "What industry/vertical is this product in?", guessedIndustry ?? "");
43
- const childrensDataRaw = await ask(lines, "Does this product knowingly collect data from children under 13/16?", "no");
44
- const tierRaw = await ask(lines, "Tier: free (static analysis only) / byok (bring your own Anthropic key) / managed (not yet available)", "free");
45
- const tier = normalizeTier(tierRaw);
103
+ const gateYes = flags.policyDetails !== undefined
104
+ ? flags.policyDetails
105
+ : /^y(es)?$/i.test((await ask(lines, "Add privacy policy details now? (company info, data practices, regional rights, etc.)", "y")).trim());
106
+ // Deliberately requires an explicit y/n -- a bare Enter, empty stdin, or a declined gate must leave
107
+ // this null (unanswered, rendered as a placeholder), never silently "no". Unlike askOptional's other
108
+ // callers, there is no safe default here: "no" is itself a real, consequential legal declaration, not
109
+ // a neutral fallback. Layer 2's high-stakes override (scan.ts) coerces null to false at its one call
110
+ // site, so leaving this unanswered never weakens that safety check -- see childrensDataOverride.test.ts.
111
+ const collectsChildrensData = await resolveField(flags.collectsChildrensData, gateYes, () => askYesNoOptional(lines, "Does this product knowingly collect data from children under 13/16?"));
112
+ const minimumAge = await resolveField(flags.minimumAge, gateYes, () => askOptionalAge(lines, "Minimum age required to use this product"));
113
+ const companyName = await resolveField(flags.companyName, gateYes, () => askOptional(lines, "Company or product name"));
114
+ const siteUrl = await resolveField(flags.siteUrl, gateYes, () => askOptional(lines, "Site or app URL"));
115
+ const privacyContactEmail = await resolveField(flags.privacyContactEmail, gateYes, () => askOptional(lines, "Privacy contact email"));
116
+ const euUkSuggested = targetMarketsSuggestEuUk(targetMarkets);
117
+ const hasEuUkUsers = await resolveField(flags.euUkUsers, gateYes, () => askYesNoOptional(lines, "Do you have users in the EU/UK?", euUkSuggested ? true : null));
118
+ warnIfContradictsTargetMarkets("EU/UK", hasEuUkUsers, euUkSuggested);
119
+ const californiaSuggested = targetMarketsSuggestCalifornia(targetMarkets);
120
+ const hasCaliforniaUsers = await resolveField(flags.californiaUsers, gateYes, () => askYesNoOptional(lines, "Do you have users in California?", californiaSuggested ? true : null));
121
+ warnIfContradictsTargetMarkets("California", hasCaliforniaUsers, californiaSuggested);
122
+ const sellsOrSharesData = await resolveField(flags.sellsOrSharesData, gateYes, () => askYesNoOptional(lines, "Does this product sell or share personal data with third parties?"));
123
+ const usesDataForAdvertising = await resolveField(flags.usesDataForAdvertising, gateYes, () => askYesNoOptional(lines, "Does this product use personal data for advertising?"));
124
+ const dataRetentionPolicy = await resolveField(flags.dataRetentionPolicy, gateYes, () => askOptional(lines, "How long is personal data retained?"));
125
+ const accountDeletionInstructions = await resolveField(flags.accountDeletionInstructions, gateYes, () => askOptional(lines, "How can a user delete their account or request data deletion?"));
126
+ // Deliberately about the SCANNED PRODUCT's own behavior (does it send its end-users' data to AI
127
+ // services as part of its own functionality) -- not about SrcLawyer's own, separately-documented
128
+ // BYOK network calls described in NETWORK_CALLS_NOTICE above.
129
+ 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?"));
130
+ // "managed" is deliberately not offered here -- it's a real SrcLawyerTier value and config.ts/
131
+ // resolveProviderForTier.ts both still support it fully (a hand-edited config with tier: managed
132
+ // still gets a clear, accurate error at scan time), but there's no advertised path to it from this
133
+ // prompt, so a tester reading the choices here can't select a dead end during setup.
134
+ const enableByokRaw = await ask(lines, "Enable optional AI-assisted review with your own Anthropic key? (advanced)", "n");
135
+ const tier = /^y(es)?$/i.test(enableByokRaw.trim()) ? "byok" : "free";
46
136
  let apiKeyEnvVar = null;
47
137
  if (tier === "byok") {
48
138
  apiKeyEnvVar = await ask(lines, "Which environment variable holds your Anthropic API key?", "ANTHROPIC_API_KEY");
49
139
  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.`);
50
140
  }
51
- else if (tier === "managed") {
52
- console.log("\nHeads up: the managed tier's hosted backend isn't available yet. Layer 1 (static analysis) will still run fine; AI-assisted resolution will report a clear error until it ships.");
53
- }
54
141
  rl.close();
55
142
  const config = {
56
143
  entityLocation: entityLocation || null,
57
- targetMarkets: targetMarketsRaw.split(",").map((s) => s.trim()).filter(Boolean),
144
+ targetMarkets,
58
145
  industry: industry || null,
59
- collectsChildrensData: /^y(es)?$/i.test(childrensDataRaw.trim()),
146
+ collectsChildrensData,
60
147
  tier,
61
148
  ...(tier === "byok" ? { llm: { provider: "anthropic", apiKeyEnvVar } } : {}),
149
+ companyName,
150
+ siteUrl,
151
+ privacyContactEmail,
152
+ hasEuUkUsers,
153
+ hasCaliforniaUsers,
154
+ sellsOrSharesData,
155
+ usesDataForAdvertising,
156
+ dataRetentionPolicy,
157
+ accountDeletionInstructions,
158
+ minimumAge,
159
+ sendsDataToAiServices,
62
160
  };
63
161
  writeConfig(root, config);
64
162
  console.log(`\nWrote ${CONFIG_FILENAME}. Run "srclawyer scan" to analyze this codebase.`);
65
163
  }
66
- function normalizeTier(raw) {
67
- const normalized = raw.trim().toLowerCase();
68
- if (normalized === "byok" || normalized === "managed")
69
- return normalized;
70
- return "free";
71
- }
@@ -8,6 +8,8 @@ import { resolveProviderForTier } from "../llm/resolveProviderForTier.js";
8
8
  import { resolveAmbiguousFindings } from "../llm/resolveAmbiguousFindings.js";
9
9
  import { resolveProtectedLogic } from "../cloud/resolveProtectedLogic.js";
10
10
  import { resolveProtectedLogicEndpoint } from "../cloud/config.js";
11
+ import { MAX_FRAGMENT_BYTES } from "../rules/zodPreFilter.js";
12
+ import { SCAN_DISCLAIMER_REMINDER, byokNetworkNotice } from "../policy/legalNotices.js";
11
13
  export async function runScan(root, options) {
12
14
  const config = loadConfig(root);
13
15
  let legacyConfigWarning = null;
@@ -22,15 +24,18 @@ export async function runScan(root, options) {
22
24
  }
23
25
  const result = await scan(root);
24
26
  let findings = result.findings;
27
+ if (result.maxZodFragmentBytes > 0) {
28
+ console.error(`Largest Zod-schema fragment this scan: ${result.maxZodFragmentBytes} bytes (hard ceiling: ${MAX_FRAGMENT_BYTES} bytes).`);
29
+ }
25
30
  // Not tier-gated, not behind --no-llm: confidence scoring and Zod-schema detection are Layer 1
26
31
  // capabilities now, needed on every real scan regardless of which LLM tier (if any) is configured.
27
32
  // A failure here degrades honestly (findings keep their safe local fallback) rather than blocking
28
33
  // the scan — see resolveProtectedLogic's own per-call error handling.
29
- const hasScorableFindings = findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0;
34
+ const hasScorableFindings = findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0 || result.oversizedZodFiles.length > 0;
30
35
  let protectedLogicWarning = null;
31
36
  if (hasScorableFindings) {
32
37
  const endpoint = resolveProtectedLogicEndpoint();
33
- const resolved = await resolveProtectedLogic(findings, result.pendingZodCandidates, endpoint);
38
+ const resolved = await resolveProtectedLogic(findings, result.pendingZodCandidates, endpoint, result.oversizedZodFiles);
34
39
  findings = resolved.findings;
35
40
  protectedLogicWarning = resolved.warning;
36
41
  if (protectedLogicWarning)
@@ -46,9 +51,16 @@ export async function runScan(root, options) {
46
51
  else if (provider) {
47
52
  const ambiguous = findings.filter((f) => f.requiresReview);
48
53
  if (ambiguous.length > 0) {
54
+ if (config.tier === "byok" && config.llm) {
55
+ console.log(byokNetworkNotice(config.llm.apiKeyEnvVar));
56
+ }
49
57
  console.log(`Sending ${ambiguous.length} ambiguous item(s) to ${provider.name}...`);
50
58
  const resolved = await resolveAmbiguousFindings(ambiguous, provider, {
51
- collectsChildrensData: config.collectsChildrensData,
59
+ // Preserves today's exact override behavior bit-for-bit: a missing/legacy key already
60
+ // behaved as false before collectsChildrensData became nullable. Only the generated
61
+ // document (generatePolicy.ts) distinguishes "explicitly no" from "never answered" -- this
62
+ // safety-critical override is unchanged.
63
+ collectsChildrensData: config.collectsChildrensData ?? false,
52
64
  });
53
65
  const resolvedById = new Map(resolved.map((f) => [f.id, f]));
54
66
  findings = findings.map((f) => resolvedById.get(f.id) ?? f);
@@ -58,7 +70,7 @@ export async function runScan(root, options) {
58
70
  const ambiguousCount = findings.filter((f) => f.requiresReview).length;
59
71
  const finalResult = { ...result, findings, ambiguousCount };
60
72
  const policyPath = resolve(root, options.out);
61
- writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError));
73
+ writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning));
62
74
  if (result.unsupportedStackWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
63
75
  process.exitCode = 1;
64
76
  }
@@ -69,5 +81,8 @@ export async function runScan(root, options) {
69
81
  console.log(formatText(finalResult));
70
82
  if (protectedLogicWarning)
71
83
  console.log(`\n${protectedLogicWarning}`);
84
+ if (legacyConfigWarning)
85
+ console.log(`\n${legacyConfigWarning}`);
72
86
  console.log(`\nPrivacy policy written to ${join(options.out)}`);
87
+ console.log(`\n${SCAN_DISCLAIMER_REMINDER}`);
73
88
  }
@@ -10,9 +10,20 @@ export interface SrcLawyerConfig {
10
10
  entityLocation: string | null;
11
11
  targetMarkets: string[];
12
12
  industry: string | null;
13
- collectsChildrensData: boolean;
13
+ collectsChildrensData: boolean | null;
14
14
  tier?: SrcLawyerTier;
15
15
  llm?: SrcLawyerLlmConfig;
16
+ companyName?: string | null;
17
+ siteUrl?: string | null;
18
+ privacyContactEmail?: string | null;
19
+ hasEuUkUsers?: boolean | null;
20
+ hasCaliforniaUsers?: boolean | null;
21
+ sellsOrSharesData?: boolean | null;
22
+ usesDataForAdvertising?: boolean | null;
23
+ dataRetentionPolicy?: string | null;
24
+ accountDeletionInstructions?: string | null;
25
+ minimumAge?: number | null;
26
+ sendsDataToAiServices?: boolean | null;
16
27
  }
17
28
  export declare const DEFAULT_CONFIG: SrcLawyerConfig;
18
29
  export declare function configPath(root: string): string;
@@ -8,8 +8,19 @@ export const DEFAULT_CONFIG = {
8
8
  entityLocation: null,
9
9
  targetMarkets: [],
10
10
  industry: null,
11
- collectsChildrensData: false,
11
+ collectsChildrensData: null,
12
12
  tier: "free",
13
+ companyName: null,
14
+ siteUrl: null,
15
+ privacyContactEmail: null,
16
+ hasEuUkUsers: null,
17
+ hasCaliforniaUsers: null,
18
+ sellsOrSharesData: null,
19
+ usesDataForAdvertising: null,
20
+ dataRetentionPolicy: null,
21
+ accountDeletionInstructions: null,
22
+ minimumAge: null,
23
+ sendsDataToAiServices: null,
13
24
  };
14
25
  export function configPath(root) {
15
26
  return join(root, CONFIG_FILENAME);
@@ -1,5 +1,6 @@
1
1
  export interface DiscoveredFiles {
2
2
  codeFiles: string[];
3
+ pythonFiles: string[];
3
4
  htmlFiles: string[];
4
5
  schemaFiles: string[];
5
6
  manifestFiles: string[];
@@ -1,5 +1,6 @@
1
1
  import fg from "fast-glob";
2
2
  const CODE_PATTERNS = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"];
3
+ const PYTHON_PATTERNS = ["**/*.py"];
3
4
  const HTML_PATTERNS = ["**/*.html", "**/*.htm"];
4
5
  const SCHEMA_PATTERNS = ["**/*.graphql", "**/*.gql", "**/openapi.yaml", "**/openapi.yml", "**/openapi.json", "**/swagger.yaml", "**/swagger.yml", "**/swagger.json"];
5
6
  const MANIFEST_PATTERNS = ["**/package.json", "**/requirements.txt"];
@@ -14,12 +15,25 @@ const IGNORE = [
14
15
  "**/*.spec.*",
15
16
  "**/*.d.ts",
16
17
  ];
18
+ // Separate from IGNORE: node_modules has no Python analogue, and excluding a venv/site-packages
19
+ // directory matters even more here, since it's a full copy of every installed pip package's own
20
+ // source -- the Python equivalent of node_modules, and just as irrelevant/misleading to scan.
21
+ const PYTHON_IGNORE = [
22
+ ...IGNORE,
23
+ "**/venv/**",
24
+ "**/.venv/**",
25
+ "**/__pycache__/**",
26
+ "**/site-packages/**",
27
+ "**/test_*.py",
28
+ "**/*_test.py",
29
+ ];
17
30
  export async function discoverFiles(root) {
18
- const [codeFiles, htmlFiles, schemaFiles, manifestFiles] = await Promise.all([
31
+ const [codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles] = await Promise.all([
19
32
  fg(CODE_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false }),
33
+ fg(PYTHON_PATTERNS, { cwd: root, absolute: true, ignore: PYTHON_IGNORE, dot: false }),
20
34
  fg(HTML_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false }),
21
35
  fg(SCHEMA_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false }),
22
36
  fg(MANIFEST_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false }),
23
37
  ]);
24
- return { codeFiles, htmlFiles, schemaFiles, manifestFiles };
38
+ return { codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles };
25
39
  }