srclawyer 0.7.0 → 0.8.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
@@ -23,10 +23,44 @@ srclawyer init
23
23
  srclawyer scan # analyze the codebase and write PRIVACY_POLICY.md
24
24
  srclawyer scan --json # findings as JSON (still writes the policy file)
25
25
  srclawyer scan --out docs/privacy.md
26
+ srclawyer scan --force # overwrite the policy even if it was edited (see below)
26
27
  ```
27
28
 
28
29
  If `srclawyer` isn't found right after a global install, see [Troubleshooting: command not found](#troubleshooting-command-not-found) below.
29
30
 
31
+ ## Editing the generated policy
32
+
33
+ `scan` regenerates `PRIVACY_POLICY.md` freely **until you edit it**. After that the file is yours, and `scan` will not overwrite it.
34
+
35
+ The generated policy has `[TO COMPLETE: …]` placeholders for facts only you know (company name, contact email, retention period, and so on). Fill them in by answering the `init` questions or by setting the same fields in `.srclawyer.config.yml`, then re-run `scan`: the placeholders are replaced. Once you have changed the file by hand (filled a placeholder in the text, reworded a section, anything), it is no longer the generated version, so complete the rest by hand too.
36
+
37
+ How it works: every generated policy ends with a marker line, `<!-- srclawyer:policy-sha256=… -->`, holding a hash of the rest of the file. Before writing, `scan` recomputes the hash.
38
+
39
+ | The existing file | What `scan` does |
40
+ |---|---|
41
+ | doesn't exist, or still matches its marker | writes the new policy |
42
+ | edited since it was generated | refuses (see below) |
43
+ | has no marker | refuses (see below) |
44
+ | any of the above, with `--force` | overwrites it |
45
+
46
+ When it refuses, a plain `scan` exits 2 before scanning anything, and prints one of these:
47
+
48
+ ```text
49
+ Not overwriting PRIVACY_POLICY.md: it has been edited since SrcLawyer generated it. Write the new policy to a separate file with --out PRIVACY_POLICY.generated.md and compare, or pass --force to replace it.
50
+ ```
51
+
52
+ ```text
53
+ Not overwriting PRIVACY_POLICY.md: it was not generated by this version of SrcLawyer (it may be hand-written, or generated before overwrite protection was added in 0.8.0). Write the new policy to a separate file with --out PRIVACY_POLICY.generated.md and compare, or pass --force to replace it.
54
+ ```
55
+
56
+ - **`--out` is guarded the same way.** `srclawyer scan --out PRIVACY_POLICY.generated.md` writes a draft you can compare with your edited policy; running it again overwrites that draft cleanly as long as you haven't edited the draft.
57
+ - **`scan --write-inventory`** still writes the drift baseline when your policy is protected. It leaves the policy untouched, says so, and exits 0. So "update the policy by hand, then refresh the baseline" works without `--force`.
58
+ - **`init`** never overwrites a policy. If `PRIVACY_POLICY.md` is already there and would be refused, it skips its "run a scan now?" question and prints the `--out` command instead.
59
+ - **The marker is an HTML comment.** It is invisible when the Markdown is rendered, but it is in the file's raw text and in the page source if you publish the Markdown as is. You can delete the line, and nothing breaks, but the file then counts as not generated by SrcLawyer and is protected from then on.
60
+ - **Formatters count as edits.** If a formatter (Prettier, a Markdown auto-fix hook) rewrites the file, it no longer matches its marker. `scan` fails safe: it refuses, and you use `--out` or `--force`.
61
+
62
+ **Upgrading from 0.7.0 or earlier.** Policies generated before 0.8.0 have no marker, so the first `scan` after upgrading refuses to overwrite one, even if you never edited it. If you never touched it, run `srclawyer scan --force` once; from then on it carries a marker. If you did edit it, run `srclawyer scan --out PRIVACY_POLICY.generated.md` and compare the two files.
63
+
30
64
  ## Development
31
65
 
32
66
  Working on SrcLawyer itself (not just using it):
@@ -122,15 +156,15 @@ Only the variable's *name* is stored in the config — the key itself is never w
122
156
  **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
157
 
124
158
  ```bash
125
- npx srclawyer scan --write-inventory # once: writes the policy and the baseline; commit the baseline
159
+ npx srclawyer scan --write-inventory # once: writes the policy (if safe to) and the baseline; commit the baseline
126
160
  npx srclawyer check # exit 0 no drift, 1 drift, 2 error, 3 inconclusive
127
161
  ```
128
162
 
129
163
  ```yaml
130
164
  - uses: actions/checkout@v7
131
- - uses: SrcLawyer/srclawyer@v0.7.0
165
+ - uses: SrcLawyer/srclawyer@v0.8.0
132
166
  with:
133
- srclawyer-version: 0.7.0 # required and pinned
167
+ srclawyer-version: 0.8.0 # required and pinned
134
168
  ```
135
169
 
136
170
  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.
@@ -144,6 +178,24 @@ Drift fails the check, and the step summary lists what was added and removed wit
144
178
 
145
179
  `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
180
 
181
+ ## Removing SrcLawyer
182
+
183
+ SrcLawyer writes at most three files into your project, and only when you ask it to:
184
+
185
+ - `.srclawyer.config.yml`, written by `srclawyer init`.
186
+ - `PRIVACY_POLICY.md`, written by `srclawyer scan` (or the path you give `--out`) unless that file has been edited, in which case [it is left alone](#editing-the-generated-policy).
187
+ - `.srclawyer.inventory.json`, written only when you pass `--write-inventory` to `scan` (or the path you give it).
188
+
189
+ Delete whichever of them you no longer want. No other SrcLawyer command writes files: `srclawyer check` is read-only, and installing the package writes nothing to your project.
190
+
191
+ SrcLawyer never creates a workflow file. To stop the [GitHub Action](#drift-check-github-action), delete or edit the workflow you added yourself (for example `.github/workflows/srclawyer.yml`).
192
+
193
+ To remove the command-line tool:
194
+
195
+ ```bash
196
+ npm uninstall -g srclawyer
197
+ ```
198
+
147
199
  ## Network calls
148
200
 
149
201
  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:
package/dist/cli.js CHANGED
@@ -83,6 +83,7 @@ program
83
83
  .option("--no-llm", "skip AI-assisted resolution of ambiguous findings for this run")
84
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
85
  .option("--allow-incomplete-inventory", "with --write-inventory: write the baseline even if the scan completed with warnings")
86
+ .option("--force", "overwrite the policy file even if it was edited since SrcLawyer generated it, or was not generated by SrcLawyer", false)
86
87
  .action(async (opts) => {
87
88
  await runScan(process.cwd(), {
88
89
  json: opts.json,
@@ -90,6 +91,7 @@ program
90
91
  llm: opts.llm,
91
92
  writeInventory: opts.writeInventory === true ? DEFAULT_INVENTORY_PATH : opts.writeInventory,
92
93
  allowIncompleteInventory: opts.allowIncompleteInventory,
94
+ force: opts.force,
93
95
  });
94
96
  });
95
97
  program
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } 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
5
  import { NETWORK_CALLS_NOTICE, byokNetworkNotice } from "../policy/legalNotices.js";
6
+ import { DEFAULT_POLICY_PATH, SUGGESTED_DRAFT_PATH, checkPolicyWrite } from "../policy/policyGuard.js";
6
7
  import { runScan } from "./scan.js";
7
8
  export const DEFAULT_API_KEY_ENV_VAR = "ANTHROPIC_API_KEY";
8
9
  const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -177,12 +178,20 @@ export async function runInit(root, flags = {}) {
177
178
  console.log(`\nWrote ${CONFIG_FILENAME}. Run "srclawyer scan" to analyze this codebase.`);
178
179
  let runScanNow = false;
179
180
  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());
181
+ // init never overwrites an existing policy. If one is there and scan would refuse to replace it,
182
+ // don't offer the scan at all; show how to generate a draft next to it instead.
183
+ const existing = checkPolicyWrite(resolve(root, DEFAULT_POLICY_PATH), DEFAULT_POLICY_PATH, false);
184
+ if (existing.allowed) {
185
+ const raw = await ask(lines, "Run a scan now and generate your policy?", "y");
186
+ runScanNow = /^y(es)?$/i.test(raw.trim());
187
+ }
188
+ else {
189
+ console.log(`\n${DEFAULT_POLICY_PATH} already exists and SrcLawyer will not overwrite it, so no scan was run. To generate a policy to compare against it, run:\n\n srclawyer scan --out ${SUGGESTED_DRAFT_PATH}`);
190
+ }
182
191
  }
183
192
  rl.close();
184
193
  if (runScanNow) {
185
194
  console.log("");
186
- await runScan(root, { json: false, out: "PRIVACY_POLICY.md", llm: true });
195
+ await runScan(root, { json: false, out: DEFAULT_POLICY_PATH, llm: true });
187
196
  }
188
197
  }
@@ -7,5 +7,7 @@ export interface ScanOptions {
7
7
  writeInventory?: string;
8
8
  /** Write the baseline even if the scan completed with warnings that make it untrustworthy. */
9
9
  allowIncompleteInventory?: boolean;
10
+ /** Overwrite the policy file even if it was edited since SrcLawyer generated it, or was not generated by SrcLawyer. */
11
+ force?: boolean;
10
12
  }
11
13
  export declare function runScan(root: string, options: ScanOptions): Promise<void>;
@@ -4,15 +4,27 @@ import { CONFIG_FILENAME, hasUnmigratedLegacyConfig, legacyConfigMessage, loadCo
4
4
  import { scan } from "../engine/scanEngine.js";
5
5
  import { formatText } from "../report/formatText.js";
6
6
  import { generatePolicyMarkdown } from "../policy/generatePolicy.js";
7
+ import { checkPolicyWrite, withPolicyMarker } from "../policy/policyGuard.js";
7
8
  import { resolveProviderForTier } from "../llm/resolveProviderForTier.js";
8
9
  import { resolveAmbiguousFindings } from "../llm/resolveAmbiguousFindings.js";
9
10
  import { resolveScanProtectedLogic } from "./protectedLogicStep.js";
10
11
  import { selectDiscovery } from "../engine/gitDiscovery.js";
11
12
  import { computeInventory, inventoryBlockers, serializeInventory } from "../inventory/inventory.js";
13
+ import { EXIT_CRASH } from "../exitCodes.js";
12
14
  import { readPackageVersion } from "../version.js";
13
15
  import { MAX_FRAGMENT_BYTES } from "../rules/zodPreFilter.js";
14
16
  import { SCAN_DISCLAIMER_REMINDER, byokNetworkNotice } from "../policy/legalNotices.js";
15
17
  export async function runScan(root, options) {
18
+ // Refuse before any scanning or network call: a plain scan exists to write this file, so if it is
19
+ // protected there is nothing useful to do. With --write-inventory the scan still runs, because the
20
+ // baseline refresh is independent of the policy -- only the policy write is skipped.
21
+ const policyPath = resolve(root, options.out);
22
+ let policyDecision = checkPolicyWrite(policyPath, options.out, options.force ?? false);
23
+ if (!policyDecision.allowed && !options.writeInventory) {
24
+ console.error(policyDecision.message);
25
+ process.exitCode = EXIT_CRASH;
26
+ return;
27
+ }
16
28
  const config = loadConfig(root);
17
29
  let legacyConfigWarning = null;
18
30
  if (!config) {
@@ -69,8 +81,16 @@ export async function runScan(root, options) {
69
81
  }
70
82
  const ambiguousCount = findings.filter((f) => f.requiresReview).length;
71
83
  const finalResult = { ...result, findings, ambiguousCount };
72
- const policyPath = resolve(root, options.out);
73
- writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning));
84
+ // Checked again now: a long scan leaves time for the file to be edited since the check above.
85
+ policyDecision = checkPolicyWrite(policyPath, options.out, options.force ?? false);
86
+ let policyNote = null;
87
+ if (policyDecision.allowed) {
88
+ writeFileSync(policyPath, withPolicyMarker(generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning)));
89
+ }
90
+ else {
91
+ policyNote = `Privacy policy not written. ${policyDecision.message}`;
92
+ console.error(policyNote);
93
+ }
74
94
  let inventoryNote = null;
75
95
  if (options.writeInventory && discovery) {
76
96
  const blockers = inventoryBlockers(result, protectedLogicWarning);
@@ -90,8 +110,12 @@ export async function runScan(root, options) {
90
110
  if (result.unsupportedStackWarning || result.workspaceWarning || result.skippedFilesWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
91
111
  process.exitCode = 1;
92
112
  }
113
+ // A refusal is a failure of what a plain scan was asked to do; with --write-inventory the policy
114
+ // was a side output and skipping it (with the note above) is not.
115
+ if (policyNote && !options.writeInventory)
116
+ process.exitCode = EXIT_CRASH;
93
117
  if (options.json) {
94
- console.log(JSON.stringify({ ...finalResult, policyPath, llmError, protectedLogicWarning, legacyConfigWarning, inventoryNote }, null, 2));
118
+ console.log(JSON.stringify({ ...finalResult, policyPath, policyWritten: policyNote === null, policySkippedReason: policyNote, llmError, protectedLogicWarning, legacyConfigWarning, inventoryNote }, null, 2));
95
119
  return;
96
120
  }
97
121
  console.log(formatText(finalResult, { aiReviewEnabled: config?.tier === "byok" }));
@@ -99,6 +123,6 @@ export async function runScan(root, options) {
99
123
  console.log(`\n${protectedLogicWarning}`);
100
124
  if (legacyConfigWarning)
101
125
  console.log(`\n${legacyConfigWarning}`);
102
- console.log(`\nPrivacy policy written to ${join(options.out)}`);
126
+ console.log(policyNote ? "\nPrivacy policy not written (see above)." : `\nPrivacy policy written to ${join(options.out)}`);
103
127
  console.log(`\n${SCAN_DISCLAIMER_REMINDER}`);
104
128
  }
@@ -0,0 +1,20 @@
1
+ /** Where `scan` writes the policy unless told otherwise. */
2
+ export declare const DEFAULT_POLICY_PATH = "PRIVACY_POLICY.md";
3
+ /** The path suggested for a side-by-side draft when the real policy is protected. */
4
+ export declare const SUGGESTED_DRAFT_PATH = "PRIVACY_POLICY.generated.md";
5
+ /** Appends the marker line to a freshly generated policy. */
6
+ export declare function withPolicyMarker(body: string): string;
7
+ export type PolicyFileState = "missing" | "unchanged" | "edited" | "unmarked";
8
+ /** What an existing file at `path` is, relative to what SrcLawyer last generated. */
9
+ export declare function classifyPolicyFile(path: string): PolicyFileState;
10
+ export type PolicyWriteDecision = {
11
+ allowed: true;
12
+ } | {
13
+ allowed: false;
14
+ kind: "edited" | "unmarked";
15
+ message: string;
16
+ };
17
+ export declare function editedMessage(path: string): string;
18
+ export declare function unmarkedMessage(path: string): string;
19
+ /** Whether `scan` may write its policy to `path`. `displayPath` is the path as the user gave it. */
20
+ export declare function checkPolicyWrite(path: string, displayPath: string, force: boolean): PolicyWriteDecision;
@@ -0,0 +1,70 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ // Overwrite protection for the generated privacy policy. Every policy SrcLawyer writes ends with a
4
+ // marker line holding a SHA-256 of the rest of the file. Before writing over an existing file we
5
+ // recompute that hash: a match means nobody has touched it since we generated it (safe to
6
+ // regenerate); anything else means it is someone's work and is left alone unless --force is passed.
7
+ // This detects changes, it is not a security feature -- anyone can recompute the hash.
8
+ const MARKER_PREFIX = "<!-- srclawyer:policy-sha256=";
9
+ const MARKER_LINE = /^<!-- srclawyer:policy-sha256=([0-9a-f]{64}) -->$/;
10
+ /** Where `scan` writes the policy unless told otherwise. */
11
+ export const DEFAULT_POLICY_PATH = "PRIVACY_POLICY.md";
12
+ /** The path suggested for a side-by-side draft when the real policy is protected. */
13
+ export const SUGGESTED_DRAFT_PATH = "PRIVACY_POLICY.generated.md";
14
+ // CRLF/CR become LF and trailing whitespace is dropped, so a Windows checkout (autocrlf) or an editor
15
+ // that adds or strips the final newline is not mistaken for a hand edit.
16
+ function normalize(text) {
17
+ return text.replace(/\r\n?/g, "\n").trimEnd();
18
+ }
19
+ function sha256(text) {
20
+ return createHash("sha256").update(text, "utf8").digest("hex");
21
+ }
22
+ /** Appends the marker line to a freshly generated policy. */
23
+ export function withPolicyMarker(body) {
24
+ const terminated = body.endsWith("\n") ? body : `${body}\n`;
25
+ return `${terminated}${MARKER_PREFIX}${sha256(normalize(body))} -->\n`;
26
+ }
27
+ /** What an existing file at `path` is, relative to what SrcLawyer last generated. */
28
+ export function classifyPolicyFile(path) {
29
+ if (!existsSync(path))
30
+ return "missing";
31
+ const lines = readFileSync(path, "utf8").replace(/\r\n?/g, "\n").split("\n");
32
+ let markerIndex = -1;
33
+ let recorded = "";
34
+ for (let i = lines.length - 1; i >= 0; i--) {
35
+ const match = MARKER_LINE.exec(lines[i].trim());
36
+ if (match) {
37
+ markerIndex = i;
38
+ recorded = match[1];
39
+ break;
40
+ }
41
+ }
42
+ if (markerIndex === -1)
43
+ return "unmarked";
44
+ // Everything except the marker line counts, including text added after it.
45
+ const rest = lines.filter((_, i) => i !== markerIndex).join("\n");
46
+ return sha256(normalize(rest)) === recorded ? "unchanged" : "edited";
47
+ }
48
+ function alternativeOut(path) {
49
+ return path === SUGGESTED_DRAFT_PATH ? "a different path" : SUGGESTED_DRAFT_PATH;
50
+ }
51
+ export function editedMessage(path) {
52
+ return `Not overwriting ${path}: it has been edited since SrcLawyer generated it. Write the new policy to a separate file with --out ${alternativeOut(path)} and compare, or pass --force to replace it.`;
53
+ }
54
+ export function unmarkedMessage(path) {
55
+ return `Not overwriting ${path}: it was not generated by this version of SrcLawyer (it may be hand-written, or generated before overwrite protection was added in 0.8.0). Write the new policy to a separate file with --out ${alternativeOut(path)} and compare, or pass --force to replace it.`;
56
+ }
57
+ /** Whether `scan` may write its policy to `path`. `displayPath` is the path as the user gave it. */
58
+ export function checkPolicyWrite(path, displayPath, force) {
59
+ if (force)
60
+ return { allowed: true };
61
+ // A directory is not a policy file; let the write fail the way it always has.
62
+ if (existsSync(path) && statSync(path).isDirectory())
63
+ return { allowed: true };
64
+ const state = classifyPolicyFile(path);
65
+ if (state === "edited")
66
+ return { allowed: false, kind: "edited", message: editedMessage(displayPath) };
67
+ if (state === "unmarked")
68
+ return { allowed: false, kind: "unmarked", message: unmarkedMessage(displayPath) };
69
+ return { allowed: true };
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srclawyer",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",