deepdebug-local-agent 1.1.0 → 1.3.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,20 @@ 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 (opt-in, off by default)
10
+
11
+ - **`LOCAL_AGENT_WS_METADATA_ONLY=off|enforce`** (T4-10, D17, D19). `enforce`
12
+ restricts the outbound WS channel to allowlisted metadata (ids, hashes,
13
+ counts, state, cost), refuses legacy content commands and denies
14
+ PolicyGate-backed actions while the control plane is unreachable. See
15
+ `docs/ws-metadata-only.md`. No behaviour change when off.
16
+ ### Added
17
+
18
+ - **DLP module (`src/dlp/`, flag `LOCAL_AGENT_DLP_MODE=off|warn|enforce`, default `off`).**
19
+ Masks secrets and PII in content leaving the agent. First wired point: the
20
+ `message` of incidents forwarded by the adapter forwarder. See `docs/dlp.md`.
21
+ With the flag off, output is unchanged.
22
+
9
23
  ### Changed defaults
10
24
 
11
25
  - **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).
@@ -0,0 +1,44 @@
1
+ # WS metadata-only mode (T4-10, D17, D19)
2
+
3
+ Flag: `LOCAL_AGENT_WS_METADATA_ONLY=off|enforce` (default `off`; an unrecognised value fails closed to `enforce`).
4
+
5
+ ## What `enforce` does
6
+
7
+ 1. **Outbound allowlist** (`src/ws-channel/allowlist.js`). Only these message types leave the agent:
8
+ `register`, `hello`, `status`, `cost` and command replies (`requestId`/`command`/`ok`/`status`/`openWorkspaces`/`errorCode`).
9
+ Fields are typed as ids, sha256 hashes, bounded integers, booleans or fixed enums. There is no free-text
10
+ field, so code, diffs, logs and chat text cannot be carried. `register` sends `workspaceHash` (sha256) instead of `workspacePath`.
11
+ 2. **Legacy content commands are refused before execution** (`workspace.file-content`, `workspace.diff`,
12
+ `workspace.safe-patch`, `mcp.*`, ...). Only `health` and `workspace.open` run; the reply is
13
+ `{ok:false, errorCode:"blocked_metadata_only"}`. Free-text error messages become `errorCode`.
14
+ 3. **Rejections are logged without content**: `[ws-metadata] BLOCKED {"reason":..,"type":..,"field":..}` (field names and codes only).
15
+ 4. **D17 transit controls**: nothing is persisted or queued by the module (blocked/over-limit messages are dropped);
16
+ a reply is sent only once, for a known request, within 60 s (TTL); 4 KiB per message; 200 messages per 10 s; 256 in-flight requests.
17
+ 5. **D19 offline = DENY**: while the tunnel is not open (or silent for more than 90 s), exec decisions that depend on
18
+ PolicyGate (signed platform tool commands, human-approval tokens) are denied with `via=policy-gate-offline`.
19
+ Allowlisted local commands do not need PolicyGate and are unaffected. Note: with `DISABLE_WS_TUNNEL=true` the agent is
20
+ permanently "offline", so these actions are always denied under `enforce` (a startup warning is logged; product decision for standalone agents pending, see Open points).
21
+ The same guard is shared with the MCP bridge (`/mcp/execute-command`), so the DENY applies to both exec entry points.
22
+
23
+ ## Reserved contract and known limits
24
+
25
+ - `hello`, `status` and `cost` are **reserved contract types: nothing emits them yet**. They are in the allowlist so that the
26
+ protocol v2 emitters (F3-05b, not merged) can be added without widening the closed world. Only `register` and command replies are sent today.
27
+ - `workspace.open` stays allowed in `enforce` (D16: VH is authoritative). It can still change local state (it sets the workspace root
28
+ from `params.root` sent by the control plane, protected by request signing); only its reply is projected to metadata.
29
+ - D19 liveness relies on the gateway answering the protocol `ping` (or the text `ping` with `pong`) within 90 s. This has **not** been
30
+ verified against the real QA gateway; validate it before enabling `enforce` in QA (open point on the PR).
31
+
32
+ ## What does not change
33
+
34
+ With the flag off (default) the tunnel, the register frame, command handling and exec policy are byte-for-byte the legacy ones.
35
+ The module opens no socket of its own. DLP (`src/dlp/*`), signing, bind and Helm are untouched.
36
+
37
+ ## Rollback
38
+
39
+ Unset `LOCAL_AGENT_WS_METADATA_ONLY` (or set `off`) and restart the agent.
40
+
41
+ ## Extending the contract
42
+
43
+ Add the type/field to `OUTBOUND_SCHEMAS` and a golden example to `test/ws-channel/allowlist.test.js` (the closed-set test fails on purpose).
44
+ The CP-side contract (F3-05b protocol v2) is not merged yet; align the schema with it when it lands.
package/env.example CHANGED
@@ -69,3 +69,14 @@ LOG_LEVEL=info
69
69
  # LOCAL_AGENT_TENANT_ID=
70
70
  # LOCAL_AGENT_WORKSPACE_ID=
71
71
  # LOCAL_AGENT_REQUIRE_WORKSPACE=false
72
+
73
+ # LOCAL_AGENT_WS_METADATA_ONLY=off|enforce (default off). enforce: the outbound WS channel to the
74
+ # control plane carries ONLY allowlisted metadata (ids, hashes, counts, state, cost) and never
75
+ # content (code, diffs, logs, chat). Legacy content commands are refused in this mode, and
76
+ # PolicyGate-backed actions (platform tool commands, human approvals) are DENIED while the agent
77
+ # has no live connection to the control plane (D19). Unrecognised values fail closed to enforce.
78
+ # LOCAL_AGENT_WS_METADATA_ONLY=off
79
+
80
+ # DLP (T4-09): mask secrets/PII before content leaves the agent. Default off.
81
+ # LOCAL_AGENT_DLP_MODE=off|warn|enforce (warn = count only, enforce = redact)
82
+ # 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.3.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
+ }