deepdebug-local-agent 1.1.0 → 1.2.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.
@@ -64,7 +64,7 @@ jobs:
64
64
  git add package-lock.json || true
65
65
  git diff --staged --quiet || git commit -m "chore: update package-lock.json [skip ci]"
66
66
 
67
- - name: Bump version (patch, or the exact dispatch input)
67
+ - name: Bump version (semantic, from the commits; or the exact dispatch input)
68
68
  env:
69
69
  INPUT_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }}
70
70
  run: |
@@ -77,15 +77,22 @@ jobs:
77
77
  LATEST_TAG="${LATEST_TAG#v}"
78
78
  PKG="$(node -p "require('./package.json').version")"
79
79
  BASE="$(printf '%s\n%s\n' "${LATEST_TAG:-0.0.0}" "$PKG" | sort -V | tail -1)"
80
- # Compute the next patch version up front and bump in ONE `npm version`
81
- # call. A separate `npm version "$BASE" --no-git-tag-version` rewrites
82
- # package.json/package-lock.json whenever the tag is ahead of
83
- # package.json, leaving the tree dirty so the following
84
- # `npm version patch` aborts with "Git working directory not clean".
85
- NEXT="$(node -e 'const [a,b,c]=process.argv[1].split(".").map(Number); console.log(`${a}.${b}.${c+1}`)' "$BASE")"
80
+ # Semantic version from the commits since the last release tag (Conventional
81
+ # Commits, see scripts/next-version.mjs and docs/releasing.md):
82
+ # BREAKING CHANGE / "type!:" -> major (X.0.0), "feat:" -> minor (0.X.0),
83
+ # anything else (fix, chore, docs, ci, ...) -> patch (0.0.X).
84
+ # Computed up front and applied in ONE `npm version` call. A separate
85
+ # `npm version "$BASE" --no-git-tag-version` rewrites package.json/
86
+ # package-lock.json whenever the tag is ahead of package.json, leaving the
87
+ # tree dirty so the following `npm version` aborts with "Git working
88
+ # directory not clean".
89
+ RANGE="HEAD"
90
+ if [ -n "${LATEST_TAG}" ] && git rev-parse -q --verify "refs/tags/v${LATEST_TAG}" >/dev/null; then
91
+ RANGE="v${LATEST_TAG}..HEAD"
92
+ fi
93
+ NEXT="$(git log -z --no-merges --format=%B "$RANGE" | node scripts/next-version.mjs "$BASE")"
86
94
  # Release override (workflow_dispatch input `version`, e.g. 1.1.0): used
87
- # as-is instead of the patch bump, so a minor/major release is not turned
88
- # into 1.1.1 by the automatic bump. Read from env, never interpolated.
95
+ # as-is instead of the computed bump (for example to force a version). Read from env, never interpolated.
89
96
  if [ -n "${INPUT_VERSION}" ]; then
90
97
  if ! printf '%s' "$INPUT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
91
98
  echo "::error::version input '$INPUT_VERSION' is not X.Y.Z"; exit 1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ Security release (security review 2026-10-11, C1 and H4). The agent now fails
6
6
  closed by default. Read "Upgrade" before rolling it out to agents whose
7
7
  platform side does not sign commands yet.
8
8
 
9
+ ### Added
10
+
11
+ - **DLP module (`src/dlp/`, flag `LOCAL_AGENT_DLP_MODE=off|warn|enforce`, default `off`).**
12
+ Masks secrets and PII in content leaving the agent. First wired point: the
13
+ `message` of incidents forwarded by the adapter forwarder. See `docs/dlp.md`.
14
+ With the flag off, output is unchanged.
15
+
9
16
  ### Changed defaults
10
17
 
11
18
  - **Signed commands: `enforce` by default.** `LOCAL_AGENT_SIGNATURE_MODE`
package/docs/dlp.md ADDED
@@ -0,0 +1,78 @@
1
+ # DLP: masking secrets and PII before content leaves the agent (T4-09)
2
+
3
+ Status: module + first integration point. Flag `LOCAL_AGENT_DLP_MODE`, default **off**.
4
+
5
+ ## Flags
6
+
7
+ | Variable | Values | Default | Effect |
8
+ |---|---|---|---|
9
+ | `LOCAL_AGENT_DLP_MODE` | `off`, `warn`, `enforce` | `off` | `off`: nothing scanned, output identical to before. `warn`: output unchanged, counters and local log report what enforce would redact. `enforce`: hits replaced by `[REDACTED:<rule_id>]`. Any other value means `off`. |
10
+ | `LOCAL_AGENT_DLP_BLOCK_CLASSES` | comma list of classes | empty | Enforce only. If a hit belongs to a listed class the whole text is replaced by `[BLOCKED:<class>]`. |
11
+ | `LOCAL_AGENT_DLP_MAX_BYTES` | integer | `2000000` | Larger inputs are not scanned; enforce fails closed (`[REDACTED:dlp_oversize]`). |
12
+
13
+ Classes: `private-key`, `cloud-credential`, `vcs-token`, `payment-key`, `saas-token`, `token`,
14
+ `credential`, `connection-string`, `url-credential`, `pii-email`, `pii-phone`, `pii-id`,
15
+ `pii-financial`, `high-entropy`.
16
+
17
+ ## API (reusable by the WebSocket work, task M)
18
+
19
+ ```js
20
+ import { maskSensitive, maskDeep, protectEgress, protectEgressAsync, protectDeep,
21
+ getDlpStats, rulesetHash, RULESET_VERSION } from './src/dlp/index.js';
22
+ ```
23
+
24
+ - `maskSensitive(text, { mode, blockClasses, maxBytes })`: **pure** function (no env, no state).
25
+ Returns `{ text, redactions: {rule_id: n}, total, blocked, blockedClasses, oversize, rulesetVersion }`.
26
+ - `maskDeep(value, opts)`: same over every string of a JSON-like value (keys untouched, input not mutated).
27
+ - `protectEgress(text, { source, log, env })`: reads the flag from the environment, updates counters,
28
+ writes a local record with counts per rule (never the value). Returns `{ text, ... }`.
29
+ - `protectEgressAsync(text, { policyDecision, timeoutMs, ... })`: as above, plus a per-action
30
+ PolicyGate decision (D19). In `enforce`, anything other than an explicit `'ALLOW'` (DENY, unknown
31
+ answer, throw, timeout, PolicyGate offline) returns `[DLP_DENIED]`.
32
+ - `protectDeep(value, opts)`: env-driven `maskDeep`.
33
+ - `getDlpStats()`: `{ mode, ruleset_version, ruleset_hash, scanned, blocked, denied, dlp_redactions_total: {rule_id: n} }`.
34
+ Counts only; intended for the heartbeat (`dlp_redactions_total` by rule). `rulesetHash()` is what
35
+ `config.update` can carry to prove which ruleset is active.
36
+
37
+ Fail-closed rules in `enforce`: internal error, oversize input, policy not ALLOW = content withheld.
38
+ In `warn`/`off` the original text is always returned.
39
+
40
+ ## Where it is applied today
41
+
42
+ - `src/siblings/incident-forwarder.js`: `message` of the incident POSTed to the gateway (log excerpt).
43
+
44
+ Not applied yet (follow-ups, outside this PR's boundary): `content.request` responses and `job.event`
45
+ (WebSocket channel, task M), prompts of the loop, error messages returned by routes. They can call
46
+ `protectEgress`/`protectDeep`/`maskSensitive` directly.
47
+
48
+ ## Ruleset (version `2026.10.28-1`, 40 rules)
49
+
50
+ Order matters (specific before generic). Highlights: PEM/OpenSSH/PGP private keys; AWS key id and secret;
51
+ GCP API key, OAuth token and client secret; Azure storage key; DigitalOcean; GitHub (classic, fine-grained);
52
+ GitLab; npm; PyPI; Stripe (keys, webhook secret); Shopify; Slack (token, webhook); Discord webhook;
53
+ SendGrid; Twilio; Mailgun; Hugging Face; Telegram; Anthropic; OpenAI; JWT; Bearer and Basic headers;
54
+ DB connection strings (postgres, mysql, mongodb, redis, amqp, mssql, cockroach); JDBC and ADO passwords;
55
+ credentials in URLs; generic `name = literal` assignments (with a validator that ignores identifiers,
56
+ calls, templates and placeholders); e-mail (ignores asset names and documentation domains);
57
+ IBAN (mod-97); card numbers (Luhn); PT NIF (checksum and keyword context); international phone
58
+ (leading `+`/`00`, 9 to 15 digits); opaque high-entropy blobs (40+ chars, ignores hex digests, UUIDs,
59
+ SRI hashes and paths). Patterns are reimplemented from public token formats; no third-party rule
60
+ files were copied. The PiiEgressMasker of the agents repo was used as a behavioural reference only.
61
+
62
+ Bump `RULESET_VERSION` in `src/dlp/rules.js` on any change.
63
+
64
+ ## Tests
65
+
66
+ `test/dlp/`: every rule has at least one synthetic positive (fixtures are built at runtime from split
67
+ prefixes and a deterministic hash stream, no real secrets); 40 negative lines (code, hashes, UUIDs,
68
+ SSH remotes, templates) must be unchanged; mode, block, oversize, idempotence, performance on
69
+ pathological 1 MB inputs, policy fail-closed and the forwarder integration (flag off = byte-identical).
70
+
71
+ Indicative false-positive check (not a measured product metric): running `enforce` over this repo
72
+ (182 text files, ~41k lines) altered about 40 lines, almost all true positives (e-mail addresses in
73
+ docs and test fixtures, literal tokens in tests). The plan's target (<= 2 % FP on a real code
74
+ corpus) still needs to be measured on a customer-like corpus during the pilot.
75
+
76
+ ## Rollback
77
+
78
+ Unset `LOCAL_AGENT_DLP_MODE` (or set `off`) and restart the agent. No data, schema or protocol changes.
package/docs/releasing.md CHANGED
@@ -1,43 +1,37 @@
1
- # Releasing the local agent
2
-
3
- Two artefacts are released: the npm package `deepdebug-local-agent` and the
4
- public BYOC image `local-agent-full`. Nothing here runs on a normal push.
5
-
6
- ## npm package
7
-
8
- `.github/workflows/npm-publish.yml` runs on every push to `main` that touches
9
- `src/**`, `package.json` or `bin/**`, and bumps the **patch** version
10
- automatically (from the highest of the latest `v*` tag and `package.json`).
11
- That is right for routine fixes and wrong for a minor release: with
12
- `package.json` at `1.1.0` the automatic bump would publish `1.1.1`.
13
-
14
- To publish an exact version (for example `1.1.0`):
15
-
16
- 1. Leave `package.json` as it is. Do **not** bump it in a PR (a push to `main`
17
- would trigger the automatic patch bump).
18
- 2. Actions → **Publish NPM Package** → *Run workflow* on `main`, input
19
- `version` = `1.1.0`. The workflow validates `X.Y.Z`, requires it to be
20
- greater than the highest existing tag and `package.json`, runs
21
- `npm version 1.1.0`, publishes with provenance, then pushes the bump commit
22
- and the `v1.1.0` tag.
23
- 3. Left empty, the input keeps the old behaviour (automatic patch bump).
24
-
25
- Prerequisite (owner of the npm package): Trusted Publisher configured for
26
- `VISIONHAMMER/deepdebug-local-agent`, workflow `npm-publish.yml` (see the
27
- header of the workflow). Without it the publish step fails and no version is
28
- burned.
29
-
30
- Heads-up: merging any PR that changes `src/**` to `main` starts an automatic
31
- patch publish (for example `1.0.39`) once the Trusted Publisher exists. To
32
- release `1.1.0` as the next version, dispatch it before merging further
33
- `src/**` changes, or merge with `[skip ci]` in the commit message.
34
-
35
- ## BYOC image (`local-agent-full`)
36
-
37
- Tags pushed by `npm-publish.yml` use `GITHUB_TOKEN` and do not trigger other
38
- workflows, so after the npm release run Actions → **Build BYOC Agent Image**
39
- on `main` with `version` = `1.1.0`
40
- (see `docs/byoc-agent-image.md`). Tick `supply_chain` to also sign the image
41
- (cosign keyless), attach the SBOM and the provenance, and verify them.
42
- For `v*` tag and release runs the same job runs when the repository variable
43
- `BYOC_SUPPLY_CHAIN` is `true`. Default: off.
1
+ # Releasing (npm)
2
+
3
+ Every push to `main` publishes `deepdebug-local-agent` to npm through
4
+ `.github/workflows/npm-publish.yml` (Trusted Publishing, OIDC; no token). The version
5
+ is chosen automatically from the commit messages since the last release tag, following
6
+ [Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org):
7
+
8
+ | Level | Version step | Commit that triggers it |
9
+ |---|---|---|
10
+ | Patch | `0.0.X` bug fix | `fix:`, and any other type (`perf:`, `refactor:`, `docs:`, `chore:`, `ci:`, ...) or a message without a type |
11
+ | Minor | `0.X.0` new functionality | `feat:` or `feat(scope):` |
12
+ | Major | `X.0.0` big, breaking change | `type!:` (for example `feat!:`) or a `BREAKING CHANGE:` footer |
13
+
14
+ The highest level among the commits since the last `vX.Y.Z` tag wins. Merge commits and the
15
+ CI bump commits (`[skip ci]`) are ignored. The rules live in `scripts/next-version.mjs` and are
16
+ unit tested in `test/next-version.test.js`.
17
+
18
+ Examples:
19
+
20
+ ```
21
+ fix: reject unsigned requests on /workspace/exec -> 1.1.0 becomes 1.1.1
22
+ feat(ws): metadata-only channel -> 1.1.1 becomes 1.2.0
23
+ feat!: require node 22 -> 1.2.0 becomes 2.0.0
24
+ ```
25
+
26
+ Tips
27
+
28
+ - Write the PR title as a Conventional Commit and use "Squash and merge", so the version
29
+ follows the title. With a normal merge, the commits inside the PR decide.
30
+ - To skip a release (for example a docs-only change), put `[skip ci]` in the merge commit
31
+ subject.
32
+ - To force a version, run the workflow by hand with the `version` input:
33
+ `gh workflow run npm-publish.yml -f version=X.Y.Z` (must be greater than the highest tag).
34
+ - The workflow tags and commits the bump only after a successful publish, so a failed
35
+ publish does not burn a version. npm reviews each new version automatically: it can stay
36
+ in "Validating" for a few minutes before it becomes installable.
37
+ - Provenance is not published (`--provenance` is rejected by npm for private repositories).
package/env.example CHANGED
@@ -69,3 +69,7 @@ LOG_LEVEL=info
69
69
  # LOCAL_AGENT_TENANT_ID=
70
70
  # LOCAL_AGENT_WORKSPACE_ID=
71
71
  # LOCAL_AGENT_REQUIRE_WORKSPACE=false
72
+
73
+ # DLP (T4-09): mask secrets/PII before content leaves the agent. Default off.
74
+ # LOCAL_AGENT_DLP_MODE=off|warn|enforce (warn = count only, enforce = redact)
75
+ # LOCAL_AGENT_DLP_BLOCK_CLASSES=private-key,cloud-credential (enforce: withhold whole text)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepdebug-local-agent",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "DeepDebug Local Agent - AI-powered code debugging assistant",
5
5
  "private": false,
6
6
  "type": "module",
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * next-version.mjs: semantic version for the automatic npm release.
4
+ *
5
+ * major (X.0.0) a breaking change: "type!:" subject or a "BREAKING CHANGE:" footer
6
+ * minor (0.X.0) new functionality: a "feat:" commit
7
+ * patch (0.0.X) everything else (fix, perf, refactor, docs, chore, ci, ...)
8
+ *
9
+ * Conventional Commits. The highest level among the commits since the last release
10
+ * tag wins. Merge commits and the CI auto-bump commits ("[skip ci]") are ignored.
11
+ *
12
+ * CLI: git log -z --no-merges --format=%B <range> | node scripts/next-version.mjs <base-version>
13
+ * prints the next version; stderr says which commit decided the bump.
14
+ */
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const SUBJECT_TYPE = /^([a-z]+)(\([^)]*\))?(!)?:\s/i
18
+ const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:\s/m
19
+
20
+ /** @param {string} message full commit message (subject + body) */
21
+ export function classify(message) {
22
+ const text = String(message || '').trim()
23
+ if (!text) return null
24
+ const subject = text.split('\n', 1)[0]
25
+ if (/\[skip ci\]/i.test(subject) || /^Merge /.test(subject)) return null
26
+ const m = SUBJECT_TYPE.exec(subject)
27
+ if (BREAKING_FOOTER.test(text) || (m && m[3])) return 'major'
28
+ if (m && m[1].toLowerCase() === 'feat') return 'minor'
29
+ return 'patch'
30
+ }
31
+
32
+ const RANK = { patch: 0, minor: 1, major: 2 }
33
+
34
+ /** @param {string[]} messages commit messages since the last release */
35
+ export function bumpFor(messages) {
36
+ let level = 'patch'
37
+ for (const msg of messages) {
38
+ const c = classify(msg)
39
+ if (c && RANK[c] > RANK[level]) level = c
40
+ }
41
+ return level
42
+ }
43
+
44
+ /** @param {string} base X.Y.Z @param {'major'|'minor'|'patch'} bump */
45
+ export function nextVersion(base, bump) {
46
+ const parts = String(base).split('.').map(Number)
47
+ if (parts.length !== 3 || parts.some((n) => !Number.isInteger(n) || n < 0)) {
48
+ throw new Error(`base version '${base}' is not X.Y.Z`)
49
+ }
50
+ const [a, b, c] = parts
51
+ if (bump === 'major') return `${a + 1}.0.0`
52
+ if (bump === 'minor') return `${a}.${b + 1}.0`
53
+ return `${a}.${b}.${c + 1}`
54
+ }
55
+
56
+ async function main() {
57
+ const base = process.argv[2]
58
+ if (!base) {
59
+ console.error('usage: next-version.mjs <base-version> < commit messages separated by NUL')
60
+ process.exit(2)
61
+ }
62
+ const chunks = []
63
+ for await (const chunk of process.stdin) chunks.push(chunk)
64
+ const messages = Buffer.concat(chunks).toString('utf8').split('\0')
65
+ const bump = bumpFor(messages)
66
+ console.error(`[next-version] ${messages.filter((m) => classify(m)).length} releasable commit(s) since the last release, bump=${bump}`)
67
+ console.log(nextVersion(base, bump))
68
+ }
69
+
70
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
71
+ main().catch((e) => {
72
+ console.error(e.message)
73
+ process.exit(1)
74
+ })
75
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * DLP engine: pure functions, no env, no I/O, no module state.
3
+ *
4
+ * maskSensitive(text, opts) -> { text, redactions, total, blocked, blockedClasses, ... }
5
+ * mode 'off' text returned untouched, nothing scanned.
6
+ * mode 'warn' text returned untouched; redactions/blocked report what enforce WOULD do.
7
+ * mode 'enforce' text returned with every hit replaced by [REDACTED:<rule_id>];
8
+ * when a hit belongs to a class in blockClasses the whole text is
9
+ * replaced by [BLOCKED:<class>] and `blocked` is true.
10
+ * The matched value is never returned, logged or counted: only rule ids and counts.
11
+ */
12
+ import { RULES, RULESET_VERSION } from './rules.js';
13
+ import { createHash } from 'node:crypto';
14
+
15
+ export const MODES = Object.freeze(['off', 'warn', 'enforce']);
16
+ export const DEFAULT_MAX_BYTES = 2_000_000;
17
+
18
+ /** @param {unknown} v @returns {'off'|'warn'|'enforce'} unknown values fall back to 'off'. */
19
+ export function normalizeMode(v) {
20
+ const m = typeof v === 'string' ? v.trim().toLowerCase() : '';
21
+ return MODES.includes(m) ? m : 'off';
22
+ }
23
+
24
+ /** Stable hash of the active ruleset (sent with config.update / heartbeat). */
25
+ export function rulesetHash(rules = RULES) {
26
+ const h = createHash('sha256');
27
+ h.update(RULESET_VERSION);
28
+ for (const rule of rules) h.update(`\n${rule.id}|${rule.class}|${rule.re.source}|${rule.re.flags}|${rule.group ?? 0}`);
29
+ return h.digest('hex');
30
+ }
31
+
32
+ export const REDACTED = (id) => `[REDACTED:${id}]`;
33
+ export const BLOCKED = (cls) => `[BLOCKED:${cls}]`;
34
+ export const OVERSIZE = '[REDACTED:dlp_oversize]';
35
+
36
+ /**
37
+ * @param {string} text
38
+ * @param {{mode?: string, rules?: ReadonlyArray<object>, blockClasses?: Iterable<string>, maxBytes?: number}} [opts]
39
+ */
40
+ export function maskSensitive(text, opts = {}) {
41
+ const mode = normalizeMode(opts.mode);
42
+ const empty = { text, mode, redactions: {}, total: 0, blocked: false, blockedClasses: [], oversize: false, rulesetVersion: RULESET_VERSION };
43
+ if (mode === 'off' || typeof text !== 'string' || text.length === 0) return empty;
44
+
45
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
46
+ if (text.length > maxBytes) {
47
+ // Cannot scan safely in bounded time: enforce fails closed, warn only reports.
48
+ return { ...empty, text: mode === 'enforce' ? OVERSIZE : text, oversize: true };
49
+ }
50
+
51
+ const rules = opts.rules ?? RULES;
52
+ const blockSet = new Set(opts.blockClasses ?? []);
53
+ const redactions = {};
54
+ const hitClasses = new Set();
55
+ let out = text;
56
+ let total = 0;
57
+
58
+ for (const rule of rules) {
59
+ const re = new RegExp(rule.re.source, rule.re.flags.includes('g') ? rule.re.flags : rule.re.flags + 'g');
60
+ out = out.replace(re, (...args) => {
61
+ const match = args[0];
62
+ const groupIdx = rule.group ?? 0;
63
+ const target = groupIdx === 0 ? match : args[groupIdx];
64
+ if (typeof target !== 'string' || target.length === 0) return match;
65
+ if (target.includes('[REDACTED:')) return match; // never re-mask a placeholder (idempotence)
66
+ if (rule.validate && !rule.validate(target)) return match;
67
+ redactions[rule.id] = (redactions[rule.id] || 0) + 1;
68
+ hitClasses.add(rule.class);
69
+ total++;
70
+ if (groupIdx === 0) return REDACTED(rule.id);
71
+ const at = match.lastIndexOf(target);
72
+ return match.slice(0, at) + REDACTED(rule.id) + match.slice(at + target.length);
73
+ });
74
+ }
75
+
76
+ const blockedClasses = [...hitClasses].filter((c) => blockSet.has(c));
77
+ const blocked = blockedClasses.length > 0;
78
+ if (mode === 'warn') return { ...empty, redactions, total, blocked, blockedClasses };
79
+ return { ...empty, text: blocked ? BLOCKED(blockedClasses[0]) : out, redactions, total, blocked, blockedClasses };
80
+ }
81
+
82
+ /**
83
+ * Apply maskSensitive to every string in a JSON-like value (objects, arrays).
84
+ * Keys are not touched. Returns a new value plus the merged report.
85
+ */
86
+ export function maskDeep(value, opts = {}) {
87
+ const report = { redactions: {}, total: 0, blocked: false, blockedClasses: [], oversize: false };
88
+ const seen = new WeakSet();
89
+ const walk = (v) => {
90
+ if (typeof v === 'string') {
91
+ const res = maskSensitive(v, opts);
92
+ for (const [k, n] of Object.entries(res.redactions)) report.redactions[k] = (report.redactions[k] || 0) + n;
93
+ report.total += res.total;
94
+ report.blocked ||= res.blocked;
95
+ report.oversize ||= res.oversize;
96
+ for (const c of res.blockedClasses) if (!report.blockedClasses.includes(c)) report.blockedClasses.push(c);
97
+ return res.text;
98
+ }
99
+ if (v === null || typeof v !== 'object') return v;
100
+ if (seen.has(v)) return '[circular]';
101
+ seen.add(v);
102
+ if (Array.isArray(v)) return v.map(walk);
103
+ const o = {};
104
+ for (const [k, x] of Object.entries(v)) o[k] = walk(x);
105
+ return o;
106
+ };
107
+ const mode = normalizeMode(opts.mode);
108
+ if (mode === 'off') return { value, mode, ...report };
109
+ const masked = walk(value);
110
+ return { value: mode === 'warn' ? value : masked, mode, ...report };
111
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * DLP for the Local Agent (T4-09): public API.
3
+ *
4
+ * Pure, reusable core (no env, no state): maskSensitive, maskDeep (engine.js)
5
+ * Configured wrappers (env + counters): protectEgress, protectEgressAsync, protectDeep
6
+ *
7
+ * Flag: LOCAL_AGENT_DLP_MODE=off|warn|enforce (default off: output byte-identical to before)
8
+ * LOCAL_AGENT_DLP_BLOCK_CLASSES=private-key,cloud-credential (default none; enforce only)
9
+ * LOCAL_AGENT_DLP_MAX_BYTES=2000000 (enforce fails closed above it)
10
+ *
11
+ * Fail-closed (D19): in enforce, an internal DLP error, an oversize input or a
12
+ * PolicyGate decision that is not an explicit ALLOW (including offline/timeout)
13
+ * withholds the content ([DLP_DENIED]) instead of letting it out.
14
+ *
15
+ * Reports carry rule ids and counts only; never the matched value.
16
+ */
17
+ import { maskSensitive, maskDeep, normalizeMode, rulesetHash, DEFAULT_MAX_BYTES } from './engine.js';
18
+ import { RULES, RULESET_VERSION, RULE_CLASSES } from './rules.js';
19
+
20
+ export { maskSensitive, maskDeep, normalizeMode, rulesetHash, RULES, RULESET_VERSION, RULE_CLASSES };
21
+
22
+ export const DENIED = '[DLP_DENIED]';
23
+ const NOOP = () => {};
24
+
25
+ /** Read the DLP configuration from an env-like object. */
26
+ export function dlpConfigFromEnv(env = process.env) {
27
+ const maxRaw = Number.parseInt(env.LOCAL_AGENT_DLP_MAX_BYTES ?? '', 10);
28
+ const blockClasses = String(env.LOCAL_AGENT_DLP_BLOCK_CLASSES ?? '')
29
+ .split(',').map((s) => s.trim()).filter((c) => RULE_CLASSES.includes(c));
30
+ return {
31
+ mode: normalizeMode(env.LOCAL_AGENT_DLP_MODE),
32
+ blockClasses,
33
+ maxBytes: Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : DEFAULT_MAX_BYTES,
34
+ };
35
+ }
36
+
37
+ // ---- counters (for heartbeat / dlp_redactions_total) -------------------------
38
+ const counters = { scanned: 0, redactedTotal: 0, blocked: 0, denied: 0, byRule: new Map() };
39
+
40
+ function record(res) {
41
+ counters.scanned++;
42
+ counters.redactedTotal += res.total;
43
+ if (res.blocked) counters.blocked++;
44
+ for (const [id, n] of Object.entries(res.redactions)) counters.byRule.set(id, (counters.byRule.get(id) || 0) + n);
45
+ }
46
+
47
+ /** Snapshot for the heartbeat: counts only. `dlp_redactions_total` is per rule id. */
48
+ export function getDlpStats(env = process.env) {
49
+ const cfg = dlpConfigFromEnv(env);
50
+ return {
51
+ mode: cfg.mode,
52
+ ruleset_version: RULESET_VERSION,
53
+ ruleset_hash: rulesetHash(),
54
+ scanned: counters.scanned,
55
+ blocked: counters.blocked,
56
+ denied: counters.denied,
57
+ dlp_redactions_total: Object.fromEntries(counters.byRule),
58
+ };
59
+ }
60
+
61
+ export function resetDlpStats() {
62
+ counters.scanned = 0; counters.redactedTotal = 0; counters.blocked = 0; counters.denied = 0;
63
+ counters.byRule.clear();
64
+ }
65
+
66
+ function localLog(log, source, res) {
67
+ if (res.total === 0 && !res.oversize) return;
68
+ try {
69
+ // Counts per rule id only. The masked value is never part of the record.
70
+ log({ dlp: true, source: source || 'unknown', mode: res.mode, total: res.total, rules: res.redactions, blocked: res.blocked, oversize: res.oversize }, '[dlp] sensitive content detected');
71
+ } catch { /* logging must never break the data path */ }
72
+ }
73
+
74
+ /**
75
+ * Mask one string leaving the agent.
76
+ * @param {string} text
77
+ * @param {{env?: object, source?: string, log?: Function}} [opts]
78
+ * @returns {{text: string, mode: string, redactions: Record<string, number>, total: number, blocked: boolean, denied: boolean}}
79
+ */
80
+ export function protectEgress(text, opts = {}) {
81
+ const cfg = dlpConfigFromEnv(opts.env ?? process.env);
82
+ if (cfg.mode === 'off') return { text, mode: 'off', redactions: {}, total: 0, blocked: false, denied: false };
83
+ try {
84
+ const res = maskSensitive(text, cfg);
85
+ record(res);
86
+ localLog(opts.log ?? NOOP, opts.source, res);
87
+ return { text: res.text, mode: cfg.mode, redactions: res.redactions, total: res.total, blocked: res.blocked, denied: false };
88
+ } catch {
89
+ if (cfg.mode !== 'enforce') return { text, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: false };
90
+ counters.denied++;
91
+ return { text: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Same as protectEgress, plus a per-action PolicyGate decision (D19).
97
+ * `policyDecision` is optional; when given, enforce requires an explicit 'ALLOW'.
98
+ * Offline, throwing, timing out or answering anything else = DENY.
99
+ * @param {string} text
100
+ * @param {{policyDecision?: () => Promise<string>|string, timeoutMs?: number, env?: object, source?: string, log?: Function}} [opts]
101
+ */
102
+ export async function protectEgressAsync(text, opts = {}) {
103
+ const cfg = dlpConfigFromEnv(opts.env ?? process.env);
104
+ if (cfg.mode === 'enforce' && typeof opts.policyDecision === 'function') {
105
+ let verdict;
106
+ let timer;
107
+ try {
108
+ const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('policy timeout')), opts.timeoutMs ?? 3000); });
109
+ verdict = await Promise.race([Promise.resolve().then(opts.policyDecision), timeout]);
110
+ } catch {
111
+ verdict = undefined;
112
+ } finally {
113
+ clearTimeout(timer);
114
+ }
115
+ if (verdict !== 'ALLOW') {
116
+ counters.denied++;
117
+ return { text: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
118
+ }
119
+ }
120
+ return protectEgress(text, opts);
121
+ }
122
+
123
+ /** Mask every string in a JSON-like payload. Returns { value, ... } with the same report fields. */
124
+ export function protectDeep(value, opts = {}) {
125
+ const cfg = dlpConfigFromEnv(opts.env ?? process.env);
126
+ if (cfg.mode === 'off') return { value, mode: 'off', redactions: {}, total: 0, blocked: false, denied: false };
127
+ try {
128
+ const res = maskDeep(value, cfg);
129
+ record(res);
130
+ localLog(opts.log ?? NOOP, opts.source, { ...res, mode: cfg.mode });
131
+ return { ...res, denied: false };
132
+ } catch {
133
+ if (cfg.mode !== 'enforce') return { value, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: false };
134
+ counters.denied++;
135
+ return { value: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
136
+ }
137
+ }