srclawyer 0.6.1 → 0.6.3

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 (38) hide show
  1. package/README.md +33 -2
  2. package/dist/cli.js +11 -1
  3. package/dist/cloud/protectedLogicClient.js +30 -6
  4. package/dist/commands/init.d.ts +5 -0
  5. package/dist/commands/init.js +36 -11
  6. package/dist/commands/scan.js +9 -3
  7. package/dist/engine/discoverFiles.d.ts +2 -1
  8. package/dist/engine/discoverFiles.js +7 -7
  9. package/dist/engine/discoverProjects.d.ts +9 -1
  10. package/dist/engine/discoverProjects.js +71 -18
  11. package/dist/engine/globFiles.d.ts +10 -0
  12. package/dist/engine/globFiles.js +12 -0
  13. package/dist/engine/languageDetection.d.ts +2 -1
  14. package/dist/engine/languageDetection.js +10 -10
  15. package/dist/engine/rootBoundary.d.ts +29 -0
  16. package/dist/engine/rootBoundary.js +102 -0
  17. package/dist/engine/scanEngine.js +174 -58
  18. package/dist/engine/scanLimits.d.ts +29 -0
  19. package/dist/engine/scanLimits.js +70 -0
  20. package/dist/engine/tsProject.d.ts +2 -1
  21. package/dist/engine/tsProject.js +40 -3
  22. package/dist/engine/types.d.ts +16 -0
  23. package/dist/engine/workspacePatterns.d.ts +23 -0
  24. package/dist/engine/workspacePatterns.js +140 -0
  25. package/dist/llm/buildRequestItem.d.ts +5 -1
  26. package/dist/llm/buildRequestItem.js +6 -2
  27. package/dist/llm/resolveAmbiguousFindings.js +7 -3
  28. package/dist/llm/resolveProviderForTier.js +1 -1
  29. package/dist/policy/generatePolicy.js +33 -8
  30. package/dist/policy/legalNotices.js +11 -7
  31. package/dist/report/formatText.d.ts +6 -1
  32. package/dist/report/formatText.js +23 -2
  33. package/dist/rules/htmlRules.d.ts +11 -0
  34. package/dist/rules/htmlRules.js +73 -17
  35. package/dist/rules/zodPreFilter.js +10 -1
  36. package/dist/schemaParsers/openapi.d.ts +6 -1
  37. package/dist/schemaParsers/openapi.js +3 -1
  38. package/package.json +2 -2
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
 
@@ -90,10 +90,41 @@ Every finding traces back to a file and line (`src/engine/types.ts#Finding`).
90
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
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
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
+ - **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
+ - **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
+
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.
93
119
 
94
120
  ## Network calls
95
121
 
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.
122
+ 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:
123
+
124
+ - **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.
125
+ - **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.
126
+
127
+ 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.
97
128
 
98
129
  ## Secret handling
99
130
 
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Command } from "commander";
6
- import { runInit } from "./commands/init.js";
6
+ import { isValidEnvVarName, runInit } from "./commands/init.js";
7
7
  import { runScan } from "./commands/scan.js";
8
8
  // Read from package.json rather than hardcoding, so --version can't silently drift out of sync with
9
9
  // the published version the way a literal string did through every prior release.
@@ -37,7 +37,15 @@ program
37
37
  .option("--minimum-age <number>", "minimum age required to use this product")
38
38
  .option("--sends-data-to-ai-services", "declare that user data is sent to third-party AI services")
39
39
  .option("--no-sends-data-to-ai-services", "declare that user data is not sent to third-party AI services")
40
+ .option("--enable-ai-review", "advanced: enable optional AI-assisted review of ambiguous findings with your own Anthropic API key (sets tier: byok)")
41
+ .option("--api-key-env-var <name>", "with --enable-ai-review: the environment variable holding your Anthropic API key (default ANTHROPIC_API_KEY)")
40
42
  .action(async (opts) => {
43
+ if (opts.apiKeyEnvVar !== undefined) {
44
+ if (!opts.enableAiReview)
45
+ program.error("error: --api-key-env-var requires --enable-ai-review");
46
+ if (!isValidEnvVarName(opts.apiKeyEnvVar))
47
+ program.error(`error: --api-key-env-var must be a valid environment variable name (got "${opts.apiKeyEnvVar}")`);
48
+ }
41
49
  let minimumAge;
42
50
  if (opts.minimumAge !== undefined) {
43
51
  const parsed = Number(opts.minimumAge);
@@ -60,6 +68,8 @@ program
60
68
  accountDeletionInstructions: opts.accountDeletionInstructions,
61
69
  minimumAge,
62
70
  sendsDataToAiServices: opts.sendsDataToAiServices,
71
+ enableAiReview: opts.enableAiReview,
72
+ apiKeyEnvVar: opts.apiKeyEnvVar,
63
73
  });
64
74
  });
65
75
  program
@@ -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);
@@ -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,7 +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
+ 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
+ }
6
12
  function guessIndustry(root) {
7
13
  const pkgPath = join(root, "package.json");
8
14
  if (!existsSync(pkgPath))
@@ -82,13 +88,18 @@ function warnIfContradictsTargetMarkets(label, finalValue, suggestedFromMarkets)
82
88
  export async function runInit(root, flags = {}) {
83
89
  const existingPath = configPath(root);
84
90
  if (existsSync(existingPath)) {
85
- console.log(`${CONFIG_FILENAME} already exists. Delete it first if you want to redo setup.`);
91
+ console.log(`${CONFIG_FILENAME} already exists -- left unchanged. To start over, delete it and re-run "srclawyer init":\n\n rm ${CONFIG_FILENAME}\n srclawyer init\n`);
86
92
  return;
87
93
  }
88
94
  if (hasUnmigratedLegacyConfig(root)) {
89
95
  console.log(legacyConfigMessage());
90
96
  return;
91
97
  }
98
+ // The final "run a scan now?" prompt below is interactive-only: skipped whenever this run is
99
+ // flag-driven (any flag was passed -- a scripted/CI invocation, even a partial one) or stdin isn't
100
+ // a real TTY (piped, redirected, non-interactive). Computed once, up front, rather than re-checked
101
+ // at the end, since nothing between here and there changes either condition.
102
+ const isInteractive = process.stdin.isTTY === true && Object.values(flags).every((v) => v === undefined);
92
103
  const guessedIndustry = guessIndustry(root);
93
104
  const rl = createInterface({ input: process.stdin, output: process.stdout });
94
105
  const lines = rl[Symbol.asyncIterator]();
@@ -126,19 +137,23 @@ export async function runInit(root, flags = {}) {
126
137
  // Deliberately about the SCANNED PRODUCT's own behavior (does it send its end-users' data to AI
127
138
  // services as part of its own functionality) -- not about SrcLawyer's own, separately-documented
128
139
  // 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";
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";
136
147
  let apiKeyEnvVar = null;
137
148
  if (tier === "byok") {
138
- 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
+ }
139
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));
140
156
  }
141
- rl.close();
142
157
  const config = {
143
158
  entityLocation: entityLocation || null,
144
159
  targetMarkets,
@@ -160,4 +175,14 @@ export async function runInit(root, flags = {}) {
160
175
  };
161
176
  writeConfig(root, config);
162
177
  console.log(`\nWrote ${CONFIG_FILENAME}. Run "srclawyer scan" to analyze this codebase.`);
178
+ let runScanNow = false;
179
+ if (isInteractive) {
180
+ const raw = await ask(lines, "Run a scan now and generate your policy?", "y");
181
+ runScanNow = /^y(es)?$/i.test(raw.trim());
182
+ }
183
+ rl.close();
184
+ if (runScanNow) {
185
+ console.log("");
186
+ await runScan(root, { json: false, out: "PRIVACY_POLICY.md", llm: true });
187
+ }
163
188
  }
@@ -24,6 +24,12 @@ export async function runScan(root, options) {
24
24
  }
25
25
  const result = await scan(root);
26
26
  let findings = result.findings;
27
+ if (result.workspaceWarning)
28
+ console.error(result.workspaceWarning);
29
+ if (result.skippedFilesWarning)
30
+ console.error(result.skippedFilesWarning);
31
+ if (result.templateSyntaxNote)
32
+ console.error(result.templateSyntaxNote);
27
33
  if (result.maxZodFragmentBytes > 0) {
28
34
  console.error(`Largest Zod-schema fragment this scan: ${result.maxZodFragmentBytes} bytes (hard ceiling: ${MAX_FRAGMENT_BYTES} bytes).`);
29
35
  }
@@ -54,7 +60,7 @@ export async function runScan(root, options) {
54
60
  if (config.tier === "byok" && config.llm) {
55
61
  console.log(byokNetworkNotice(config.llm.apiKeyEnvVar));
56
62
  }
57
- console.log(`Sending ${ambiguous.length} ambiguous item(s) to ${provider.name}...`);
63
+ console.log(`Sending ${ambiguous.length} ambiguous item(s) to ${provider.name} (an opaque id, category, description and one redacted source line each)...`);
58
64
  const resolved = await resolveAmbiguousFindings(ambiguous, provider, {
59
65
  // Preserves today's exact override behavior bit-for-bit: a missing/legacy key already
60
66
  // behaved as false before collectsChildrensData became nullable. Only the generated
@@ -71,14 +77,14 @@ export async function runScan(root, options) {
71
77
  const finalResult = { ...result, findings, ambiguousCount };
72
78
  const policyPath = resolve(root, options.out);
73
79
  writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning));
74
- if (result.unsupportedStackWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
80
+ if (result.unsupportedStackWarning || result.workspaceWarning || result.skippedFilesWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
75
81
  process.exitCode = 1;
76
82
  }
77
83
  if (options.json) {
78
84
  console.log(JSON.stringify({ ...finalResult, policyPath, llmError, protectedLogicWarning, legacyConfigWarning }, null, 2));
79
85
  return;
80
86
  }
81
- console.log(formatText(finalResult));
87
+ console.log(formatText(finalResult, { aiReviewEnabled: config?.tier === "byok" }));
82
88
  if (protectedLogicWarning)
83
89
  console.log(`\n${protectedLogicWarning}`);
84
90
  if (legacyConfigWarning)
@@ -1,3 +1,4 @@
1
+ import type { RootBoundary } from "./rootBoundary.js";
1
2
  export interface DiscoveredFiles {
2
3
  codeFiles: string[];
3
4
  pythonFiles: string[];
@@ -5,4 +6,4 @@ export interface DiscoveredFiles {
5
6
  schemaFiles: string[];
6
7
  manifestFiles: string[];
7
8
  }
8
- export declare function discoverFiles(root: string): Promise<DiscoveredFiles>;
9
+ export declare function discoverFiles(root: string, boundary: RootBoundary): Promise<DiscoveredFiles>;
@@ -1,4 +1,4 @@
1
- import fg from "fast-glob";
1
+ import { globFiles } from "./globFiles.js";
2
2
  const CODE_PATTERNS = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"];
3
3
  const PYTHON_PATTERNS = ["**/*.py"];
4
4
  const HTML_PATTERNS = ["**/*.html", "**/*.htm"];
@@ -27,13 +27,13 @@ const PYTHON_IGNORE = [
27
27
  "**/test_*.py",
28
28
  "**/*_test.py",
29
29
  ];
30
- export async function discoverFiles(root) {
30
+ export async function discoverFiles(root, boundary) {
31
31
  const [codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles] = await Promise.all([
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 }),
34
- fg(HTML_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false }),
35
- fg(SCHEMA_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false }),
36
- fg(MANIFEST_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false }),
32
+ globFiles(CODE_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false, boundary }),
33
+ globFiles(PYTHON_PATTERNS, { cwd: root, absolute: true, ignore: PYTHON_IGNORE, dot: false, boundary }),
34
+ globFiles(HTML_PATTERNS, { cwd: root, absolute: true, ignore: IGNORE, dot: false, boundary }),
35
+ globFiles(SCHEMA_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false, boundary }),
36
+ globFiles(MANIFEST_PATTERNS, { cwd: root, absolute: true, ignore: ["**/node_modules/**"], dot: false, boundary }),
37
37
  ]);
38
38
  return { codeFiles, pythonFiles, htmlFiles, schemaFiles, manifestFiles };
39
39
  }
@@ -1,3 +1,11 @@
1
+ import type { RootBoundary } from "./rootBoundary.js";
1
2
  import type { ProjectManifest } from "./types.js";
2
- export declare function discoverProjects(root: string): Promise<ProjectManifest[]>;
3
+ export interface ProjectDiscovery {
4
+ projects: ProjectManifest[];
5
+ /** Non-null when the scanned repo's own workspace config was rejected (malformed, hostile, or too
6
+ * expensive to expand) and the default package locations were scanned instead -- surfaced on the
7
+ * console and in the generated policy, since the scan may then be incomplete. */
8
+ workspaceWarning: string | null;
9
+ }
10
+ export declare function discoverProjects(root: string, boundary: RootBoundary): Promise<ProjectDiscovery>;
3
11
  export declare function describeProject(root: string, project: ProjectManifest): string;
@@ -1,32 +1,85 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
2
  import { dirname, join, relative } from "node:path";
3
- import fg from "fast-glob";
4
- export async function discoverProjects(root) {
3
+ import { globFiles } from "./globFiles.js";
4
+ import { validateWorkspacePatterns } from "./workspacePatterns.js";
5
+ const DEFAULT_WORKSPACE_PATTERNS = ["packages/*", "apps/*"];
6
+ function rejectedConfigWarning(reason) {
7
+ return `Workspace configuration in package.json was rejected (${reason}); the default package locations (${DEFAULT_WORKSPACE_PATTERNS.join(", ")}) were scanned instead, so this scan may be incomplete.`;
8
+ }
9
+ function readPackageJson(path, boundary) {
10
+ if (!boundary.allowsPath(path))
11
+ return { error: "package.json resolves outside the scanned root" };
12
+ try {
13
+ // A leading BOM (common from Windows editors) is valid in practice but not to JSON.parse.
14
+ const text = readFileSync(path, "utf8").replace(/^/, "");
15
+ return { value: JSON.parse(text) };
16
+ }
17
+ catch {
18
+ return { error: "package.json could not be parsed" };
19
+ }
20
+ }
21
+ // undefined means "no workspace config at all"; anything else (including a wrong type) is handed to
22
+ // the validator, which rejects what isn't an array of well-formed patterns.
23
+ function rawWorkspaces(pkg) {
24
+ const workspaces = typeof pkg === "object" && pkg !== null ? pkg.workspaces : undefined;
25
+ if (workspaces === undefined || workspaces === null)
26
+ return undefined;
27
+ if (typeof workspaces === "object" && !Array.isArray(workspaces))
28
+ return workspaces.packages ?? undefined;
29
+ return workspaces;
30
+ }
31
+ async function globManifests(patterns, root, boundary) {
32
+ return globFiles(patterns.map((p) => (p.startsWith("!") ? `!${join(p.slice(1), "package.json")}` : join(p, "package.json"))), { cwd: root, absolute: true, ignore: ["**/node_modules/**"], boundary });
33
+ }
34
+ export async function discoverProjects(root, boundary) {
5
35
  const rootPackageJsonPath = join(root, "package.json");
6
36
  if (!existsSync(rootPackageJsonPath)) {
7
- return [{ root, kind: "unknown", manifestPath: null }];
8
- }
9
- const rootPackageJson = JSON.parse(readFileSync(rootPackageJsonPath, "utf8"));
10
- const workspacePatterns = Array.isArray(rootPackageJson.workspaces)
11
- ? rootPackageJson.workspaces
12
- : rootPackageJson.workspaces?.packages;
13
- const lernaPath = join(root, "lerna.json");
14
- const nxPath = join(root, "nx.json");
15
- const hasWorkspaceConfig = Boolean(workspacePatterns) || existsSync(lernaPath) || existsSync(nxPath);
37
+ return { projects: [{ root, kind: "unknown", manifestPath: null }], workspaceWarning: null };
38
+ }
39
+ const rootProject = { root, kind: "node", manifestPath: rootPackageJsonPath };
40
+ const parsed = readPackageJson(rootPackageJsonPath, boundary);
41
+ let workspaceWarning = null;
42
+ let patterns = null;
43
+ let hasWorkspaceConfig = existsSync(join(root, "lerna.json")) || existsSync(join(root, "nx.json"));
44
+ if ("error" in parsed) {
45
+ workspaceWarning = rejectedConfigWarning(parsed.error);
46
+ hasWorkspaceConfig = true;
47
+ }
48
+ else {
49
+ const raw = rawWorkspaces(parsed.value);
50
+ if (raw !== undefined) {
51
+ hasWorkspaceConfig = true;
52
+ const validation = validateWorkspacePatterns(raw);
53
+ if (validation.ok)
54
+ patterns = validation.patterns;
55
+ else
56
+ workspaceWarning = rejectedConfigWarning(validation.reason);
57
+ }
58
+ }
16
59
  if (!hasWorkspaceConfig) {
17
- return [{ root, kind: "node", manifestPath: rootPackageJsonPath }];
60
+ return { projects: [rootProject], workspaceWarning: null };
61
+ }
62
+ let manifestPaths;
63
+ try {
64
+ manifestPaths = await globManifests(patterns ?? DEFAULT_WORKSPACE_PATTERNS, root, boundary);
65
+ }
66
+ catch {
67
+ // Belt and braces behind validateWorkspacePatterns: a pattern that still fails to expand must
68
+ // never take the whole scan down.
69
+ workspaceWarning = rejectedConfigWarning("the patterns could not be expanded");
70
+ try {
71
+ manifestPaths = await globManifests(DEFAULT_WORKSPACE_PATTERNS, root, boundary);
72
+ }
73
+ catch {
74
+ manifestPaths = [];
75
+ }
18
76
  }
19
- const patterns = workspacePatterns ?? ["packages/*", "apps/*"];
20
- const manifestPaths = await fg(patterns.map((p) => join(p, "package.json")), { cwd: root, absolute: true, ignore: ["**/node_modules/**"] });
21
77
  const projects = manifestPaths.map((manifestPath) => ({
22
78
  root: dirname(manifestPath),
23
79
  kind: "node",
24
80
  manifestPath,
25
81
  }));
26
- if (projects.length === 0) {
27
- return [{ root, kind: "node", manifestPath: rootPackageJsonPath }];
28
- }
29
- return projects;
82
+ return { projects: projects.length === 0 ? [rootProject] : projects, workspaceWarning };
30
83
  }
31
84
  export function describeProject(root, project) {
32
85
  const rel = relative(root, project.root);
@@ -0,0 +1,10 @@
1
+ import type { RootBoundary } from "./rootBoundary.js";
2
+ export interface GlobFilesOptions {
3
+ cwd: string;
4
+ absolute?: boolean;
5
+ ignore?: string[];
6
+ dot?: boolean;
7
+ /** When given, symlinks resolving outside the scanned root are neither walked nor returned. */
8
+ boundary?: RootBoundary;
9
+ }
10
+ export declare function globFiles(patterns: string | string[], options: GlobFilesOptions): Promise<string[]>;
@@ -0,0 +1,12 @@
1
+ import { glob } from "tinyglobby";
2
+ // expandDirectories: false keeps tinyglobby's matching identical to the fast-glob behavior this
3
+ // replaced (a pattern matches only what it names, never everything under a matched directory).
4
+ // tinyglobby/picomatch compiles braces to regex alternation rather than expanding them, so it has no
5
+ // equivalent of the `braces` package's stack-overflow/memory exposure (GHSA-vfj7-8cjw-p6xm); patterns
6
+ // that come from a scanned repo are still bounded first, in workspacePatterns.ts.
7
+ // Sorted so scan order (and therefore the order of findings in reports) doesn't depend on filesystem
8
+ // traversal order, which neither fast-glob nor tinyglobby guarantees.
9
+ export async function globFiles(patterns, options) {
10
+ const { boundary, ...globOptions } = options;
11
+ return (await glob(patterns, { expandDirectories: false, ...globOptions, ...(boundary ? { fs: boundary.globFs() } : {}) })).sort();
12
+ }
@@ -1 +1,2 @@
1
- export declare function detectUnsupportedStack(root: string): Promise<string | null>;
1
+ import type { RootBoundary } from "./rootBoundary.js";
2
+ export declare function detectUnsupportedStack(root: string, boundary: RootBoundary): Promise<string | null>;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import fg from "fast-glob";
3
+ import { globFiles } from "./globFiles.js";
4
4
  const IGNORE = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**", "**/vendor/**", "**/venv/**", "**/.venv/**", "**/__pycache__/**"];
5
5
  // Python moved from UNSUPPORTED_LANGUAGES to here once Flask detection shipped (Stage A) -- it's a
6
6
  // supported *language* now, but only one framework within it is actually understood. A Python project
@@ -17,7 +17,7 @@ const FASTAPI_IMPORT = /\bfrom\s+fastapi\b|\bimport\s+fastapi\b/;
17
17
  function bannerize(message) {
18
18
  return `UNSUPPORTED CODEBASE — ${message} SrcLawyer only analyzes JavaScript/TypeScript, HTML, and Flask-based Python today; the findings below (if any) are incomplete and MUST NOT be treated as a full compliance scan.`;
19
19
  }
20
- async function detectFrameworkSignature(root) {
20
+ async function detectFrameworkSignature(root, boundary) {
21
21
  if (existsSync(join(root, "manage.py"))) {
22
22
  return bannerize("Django detected (manage.py found at the project root) — Django support isn't built yet.");
23
23
  }
@@ -26,7 +26,7 @@ async function detectFrameworkSignature(root) {
26
26
  if (hasGemfile && hasRailsLayout) {
27
27
  return bannerize("Rails detected (Gemfile + a Rails app/config layout found).");
28
28
  }
29
- const protoFiles = await fg("**/*.proto", { cwd: root, ignore: IGNORE, dot: false });
29
+ const protoFiles = await globFiles("**/*.proto", { cwd: root, ignore: IGNORE, dot: false, boundary });
30
30
  if (protoFiles.length > 0) {
31
31
  return bannerize(`gRPC/Protocol Buffers detected (${protoFiles.length} .proto file(s) found) — protobuf schemas are not parsed.`);
32
32
  }
@@ -53,10 +53,10 @@ async function detectUnsupportedPythonFramework(root, pythonFiles) {
53
53
  }
54
54
  return null;
55
55
  }
56
- async function detectDominantUnsupportedLanguage(root) {
56
+ async function detectDominantUnsupportedLanguage(root, boundary) {
57
57
  const [supportedFiles, ...unsupportedGroups] = await Promise.all([
58
- fg(SUPPORTED_EXTENSIONS.map((ext) => `**/*.${ext}`), { cwd: root, ignore: IGNORE, dot: false }),
59
- ...UNSUPPORTED_LANGUAGES.map((lang) => fg(lang.extensions.map((ext) => `**/*.${ext}`), { cwd: root, ignore: IGNORE, dot: false })),
58
+ globFiles(SUPPORTED_EXTENSIONS.map((ext) => `**/*.${ext}`), { cwd: root, ignore: IGNORE, dot: false, boundary }),
59
+ ...UNSUPPORTED_LANGUAGES.map((lang) => globFiles(lang.extensions.map((ext) => `**/*.${ext}`), { cwd: root, ignore: IGNORE, dot: false, boundary })),
60
60
  ]);
61
61
  const counts = UNSUPPORTED_LANGUAGES.map((lang, i) => ({ label: lang.label, count: unsupportedGroups[i].length }));
62
62
  const dominant = counts.filter((c) => c.count > 0).sort((a, b) => b.count - a.count)[0];
@@ -64,15 +64,15 @@ async function detectDominantUnsupportedLanguage(root) {
64
64
  return null;
65
65
  return bannerize(`${dominant.label} detected as the primary language (${dominant.count} file(s), vs ${supportedFiles.length} supported file(s)).`);
66
66
  }
67
- export async function detectUnsupportedStack(root) {
68
- const frameworkWarning = await detectFrameworkSignature(root);
67
+ export async function detectUnsupportedStack(root, boundary) {
68
+ const frameworkWarning = await detectFrameworkSignature(root, boundary);
69
69
  if (frameworkWarning)
70
70
  return frameworkWarning;
71
- const pythonFiles = await fg("**/*.py", { cwd: root, absolute: true, ignore: IGNORE, dot: false });
71
+ const pythonFiles = await globFiles("**/*.py", { cwd: root, absolute: true, ignore: IGNORE, dot: false, boundary });
72
72
  if (pythonFiles.length > 0) {
73
73
  const pythonFrameworkWarning = await detectUnsupportedPythonFramework(root, pythonFiles);
74
74
  if (pythonFrameworkWarning)
75
75
  return pythonFrameworkWarning;
76
76
  }
77
- return detectDominantUnsupportedLanguage(root);
77
+ return detectDominantUnsupportedLanguage(root, boundary);
78
78
  }
@@ -0,0 +1,29 @@
1
+ import { realpath, realpathSync } from "node:fs";
2
+ /**
3
+ * The scanned root as a security boundary: a scanned repo is untrusted input, so no file whose REAL
4
+ * path resolves outside it may be read, and no directory symlink leaving it may be walked (a symlink
5
+ * to "/" or "~" would otherwise make a scan traverse the whole filesystem and report lines from files
6
+ * the repo's author chose). In-root symlinks, including loops that stay inside, keep working.
7
+ */
8
+ export declare class RootBoundary {
9
+ readonly root: string;
10
+ readonly realRoot: string;
11
+ private readonly blocked;
12
+ private readonly resolved;
13
+ constructor(root: string);
14
+ isInsideRealPath(resolved: string): boolean;
15
+ /** Records a blocked link by its in-repo (lexical) path only -- never its target. */
16
+ private recordBlocked;
17
+ /** Read-time check, independent of how the path was discovered: the real path if it is inside the
18
+ * root, else null (and an out-of-root link is recorded). Unresolvable paths fail closed. Cached. */
19
+ resolveInside(path: string): string | null;
20
+ allowsPath(path: string): boolean;
21
+ /** `fs` overrides for tinyglobby: a symlink resolving outside the root errors out, so it is neither
22
+ * walked (directories) nor returned (files). */
23
+ globFs(): {
24
+ realpath: typeof realpath;
25
+ realpathSync: typeof realpathSync;
26
+ };
27
+ /** Relative in-repo paths of every blocked link, sorted; call after discovery and reading are done. */
28
+ blockedLinks(): string[];
29
+ }