@clear-capabilities/agentic-security-scanner 0.143.0 → 0.144.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/bin/agentic-security.js +477 -47
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +51 -2
  93. package/src/report/oscal.js +7 -2
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -0,0 +1,313 @@
1
+ // Central egress policy (assurance-hardening PRD FR-601).
2
+ //
3
+ // Every outbound call this codebase makes to an LLM provider must be
4
+ // evaluated HERE before its prompt is constructed and before any network
5
+ // client touches the endpoint. A denied call must produce no network
6
+ // request and a machine-readable decision object — never a silent no-op
7
+ // and never a request that fires anyway with the decision merely logged
8
+ // after the fact.
9
+ //
10
+ // SCOPE (this is FR-601 only, not the fuller FR-602-607 cluster):
11
+ // - A real allow/deny decision, evaluated before prompt construction.
12
+ // - `mode: deny` / `mode: local-only` / provider allow-deny lists as the
13
+ // concrete denial conditions this cycle implements, because FR-601's
14
+ // acceptance criterion ("a denied call results in no network request")
15
+ // requires at least one genuine, testable deny path to exist.
16
+ // - Deliberately NOT in scope here: redaction of the outbound payload
17
+ // (FR-603, already partially done by llm-validator/redact.js for its
18
+ // own path only), and persistent per-call audit records (FR-604). Each
19
+ // remains separate, tracked future work.
20
+ // - FR-602 (provider/model/role/region/repository/path/data-class/
21
+ // max-context constraints) IS in scope as of the FR-602 cycle — see the
22
+ // dimension loop and the path/token checks in evaluateEgress below.
23
+ // Each new dimension is evaluated ONLY when the caller supplies the
24
+ // corresponding ctx value AND the config restricts that dimension —
25
+ // a caller with no opinion on, say, region is never blocked by a
26
+ // region rule it has no way to satisfy or violate. This mirrors the
27
+ // provider dimension's own pre-existing shape (deniedX/allowedX list
28
+ // membership) for model/role/region/repository/data-class; path uses
29
+ // glob matching (util/glob.js's matchesAnyGlob, already used elsewhere
30
+ // in this codebase for ignore-pattern matching) since a single literal
31
+ // string is the wrong shape for a file path constraint; max-context
32
+ // is a numeric cap on an ESTIMATED token count the caller computes
33
+ // itself (evaluateEgress runs before prompt construction, so it has
34
+ // no prompt to measure — the caller must estimate from what it is
35
+ // about to build).
36
+ // - FR-605 (unambiguous local-only mode, anti-URL-smuggling) IS in scope
37
+ // as of the FR-605 cycle: `mode: local-only` here calls the same
38
+ // literal-only `isLoopbackUrl` check `local-endpoint.js` uses for its
39
+ // own preset, and every real HTTP-calling caller in the codebase
40
+ // (llm-validator, discovery/llm-invoke's single- AND consensus-endpoint
41
+ // paths, adversary-agent's own default path, flow-narration,
42
+ // sca/llm-function-extract) routes through this function before
43
+ // dialing out — verified by tracing every reference to
44
+ // AGENTIC_SECURITY_LLM_ENDPOINT in the codebase, not assumed. The one
45
+ // real gap found and closed in that pass: discovery/llm-invoke.js's
46
+ // multi-endpoint consensus mode used to construct its caller with NO
47
+ // egress check at all, so a `mode: local-only` policy was silently
48
+ // bypassable by setting AGENTIC_SECURITY_LLM_ENDPOINTS instead of the
49
+ // single-endpoint var. Fixed there, not here — see that file's own
50
+ // comment on `makeConsensusInvoke`.
51
+ // - FR-607 (approved-provider metadata for regulated profiles) IS in
52
+ // scope as of the FR-607 cycle: `regulatedProfile.requireApprovedProviders`
53
+ // is a no-op unless configured; once set, every provider must have a
54
+ // matching `approvedProviders[provider]` entry carrying a non-empty
55
+ // value for each required attribute (default: dpaStatus, baaStatus,
56
+ // retentionPolicy) or the call is denied. Organizational/contractual
57
+ // facts, never inferred — same discipline
58
+ // dataflow/privacy-governance.js's GOVERNANCE_FIELDS already
59
+ // established for RoPA/DPIA.
60
+ //
61
+ // DEFAULT BEHAVIOR. No config file and no env override => 'allow'. This
62
+ // preserves the existing default-on UX for every LLM-backed feature in this
63
+ // codebase (discovery, validation, adversary-agent, etc.) — FR-601 asks for
64
+ // a policy DECISION on every call, not a policy that silently disables
65
+ // functionality nobody configured it to disable.
66
+
67
+ import * as fs from 'node:fs';
68
+ import { load as loadYaml } from '../util/yaml.js';
69
+ import { statePath } from '../posture/state-dir.js';
70
+ import { isLoopbackUrl } from '../llm-validator/local-endpoint.js';
71
+ import { matchesAnyGlob } from '../util/glob.js';
72
+
73
+ // FR-602: the list-membership dimensions, keyed by ctx field name to the
74
+ // config's allow/deny list PAIR name. `provider` (FR-601) already used
75
+ // this exact shape as allowedProviders/deniedProviders; the rest are new.
76
+ const LIST_DIMENSIONS = [
77
+ { ctxKey: 'model', allowKey: 'allowedModels', denyKey: 'deniedModels' },
78
+ { ctxKey: 'role', allowKey: 'allowedRoles', denyKey: 'deniedRoles' },
79
+ { ctxKey: 'region', allowKey: 'allowedRegions', denyKey: 'deniedRegions' },
80
+ { ctxKey: 'repository', allowKey: 'allowedRepositories', denyKey: 'deniedRepositories' },
81
+ { ctxKey: 'dataClass', allowKey: 'allowedDataClasses', denyKey: 'deniedDataClasses' },
82
+ ];
83
+
84
+ const POLICY_FILE = 'egress-policy.yml';
85
+
86
+ // FR-607: when a regulated profile requires approved-provider metadata, an
87
+ // approvedProviders[provider] entry must carry a non-empty value for each
88
+ // of these attributes unless the operator names a different set via
89
+ // regulatedProfile.requiredAttributes. DPA (Data Processing Agreement) and
90
+ // BAA (Business Associate Agreement, HIPAA) are organizational/contractual
91
+ // facts — not derivable from code, same "state it, never infer it"
92
+ // discipline dataflow/privacy-governance.js's GOVERNANCE_FIELDS already
93
+ // established for RoPA/DPIA fields.
94
+ const DEFAULT_REQUIRED_PROVIDER_ATTRIBUTES = ['dpaStatus', 'baaStatus', 'retentionPolicy'];
95
+
96
+ // FR-603 (egress/redact.js) reads the same egress-policy.yml file for its
97
+ // own keys (proprietaryPaths, customerDataPatterns, redactPii) — one
98
+ // operator-facing config surface for the whole egress epic rather than a
99
+ // second file. Exported (not `_`-prefixed like the rest of this module's
100
+ // internals) for that reuse; still not part of the public evaluateEgress
101
+ // API surface.
102
+ export function loadPolicyConfig(scanRoot) {
103
+ let fp;
104
+ try { fp = statePath(scanRoot, POLICY_FILE); } catch { return null; }
105
+ // Read first, check second — an existsSync-then-readFileSync pair is a
106
+ // check-then-use race (the file can vanish between the two calls); this
107
+ // codebase's convention (apply-fix-service.js's readVerifiedScan) is to
108
+ // let the read fail and classify ENOENT rather than pre-check existence.
109
+ let raw;
110
+ try {
111
+ raw = fs.readFileSync(fp, 'utf8');
112
+ } catch (e) {
113
+ return null; // ENOENT (no config file) or any other read failure — same fallback either way
114
+ }
115
+ try {
116
+ const doc = loadYaml(raw);
117
+ return (doc && typeof doc === 'object' && !Array.isArray(doc)) ? doc : null;
118
+ } catch { return null; }
119
+ }
120
+
121
+ /** Best-effort, purely informational provider label from the endpoint host. */
122
+ function _providerOf(endpoint) {
123
+ let u;
124
+ try { u = new URL(String(endpoint)); } catch { return 'unknown'; }
125
+ if (isLoopbackUrl(endpoint)) return 'local';
126
+ const h = u.hostname.toLowerCase();
127
+ if (h.includes('anthropic')) return 'anthropic';
128
+ if (h.includes('openai')) return 'openai';
129
+ if (h.includes('googleapis') || h.includes('generativelanguage')) return 'google';
130
+ return h || 'unknown';
131
+ }
132
+
133
+ /**
134
+ * Evaluate whether an outbound LLM call is permitted. Call sites MUST check
135
+ * `.allowed` before building the prompt for that call and before invoking
136
+ * any fetch/HTTP client against `ctx.endpoint`.
137
+ *
138
+ * @param {object} ctx
139
+ * @param {string} [ctx.scanRoot] - project root, for reading
140
+ * `.agentic-security/egress-policy.yml`; a missing/unreadable root is
141
+ * treated the same as no config file (falls back to 'allow').
142
+ * @param {string} ctx.purpose - short, stable id of the call site (e.g.
143
+ * 'discovery-hunter', 'llm-validator', 'adversary-agent') — never
144
+ * free text, so a decision is comparable across runs and never carries
145
+ * source content.
146
+ * @param {string} ctx.endpoint - the URL that would be called. Required —
147
+ * a caller with no configured endpoint should never reach this function
148
+ * at all (every call site already has its own pre-existing "is anything
149
+ * configured" check upstream of prompt construction).
150
+ * @param {string} [ctx.model] - FR-602: the model id the caller intends to
151
+ * use, if known. Evaluated against allowedModels/deniedModels only when
152
+ * supplied.
153
+ * @param {string} [ctx.role] - FR-602: which ROLE this call serves (the
154
+ * same closed vocabulary llm-validator/providers.js's ROLES uses, but
155
+ * this function does not require membership in it — a caller in a
156
+ * different subsystem may have its own role names). Evaluated against
157
+ * allowedRoles/deniedRoles only when supplied.
158
+ * @param {string} [ctx.region] - FR-602: a data-residency/provider region
159
+ * identifier, if the caller knows one. This codebase does not derive a
160
+ * region for any provider today; it is purely caller-supplied and
161
+ * evaluated against allowedRegions/deniedRegions only when supplied.
162
+ * @param {string} [ctx.repository] - FR-602: a repository identifier for
163
+ * the current scan (e.g. a git remote or a stable project label), if the
164
+ * caller knows one. Evaluated against allowedRepositories/
165
+ * deniedRepositories only when supplied.
166
+ * @param {string} [ctx.path] - FR-602: a '/'-separated relative path
167
+ * representative of the content in this call (e.g. the finding's source
168
+ * file), if applicable. Evaluated by glob against allowedPaths/
169
+ * deniedPaths only when supplied.
170
+ * @param {string} [ctx.dataClass] - FR-602: a regulated-data-class label
171
+ * (e.g. from dataflow/privacy-taxonomy.js's taxonomy) present in the
172
+ * content this call would send, if the caller has classified it.
173
+ * Evaluated against allowedDataClasses/deniedDataClasses only when
174
+ * supplied.
175
+ * @param {number} [ctx.contextTokens] - FR-602: the caller's OWN estimate
176
+ * of the prompt's token size (this function runs before prompt
177
+ * construction, so it cannot measure one itself). Compared against
178
+ * `maxContextTokens` in config only when both are present.
179
+ * @returns {{allowed:boolean, decision:'allow'|'deny', reason:string|null,
180
+ * provider:string, policySource:'default'|'env'|'config', purpose:string}}
181
+ * Sanitized — carries no prompt/source content, only metadata, so it is
182
+ * always safe to log or attach to a report as-is (PRD walkthrough
183
+ * scenario 7: "records a sanitized decision without retaining source").
184
+ */
185
+ export function evaluateEgress(ctx = {}) {
186
+ const {
187
+ scanRoot, purpose = 'unknown', endpoint,
188
+ model = null, role = null, region = null, repository = null,
189
+ path: filePath = null, dataClass = null, contextTokens = null,
190
+ } = ctx;
191
+
192
+ if (!endpoint || typeof endpoint !== 'string') {
193
+ return { allowed: false, decision: 'deny', reason: 'no endpoint provided to evaluateEgress', provider: 'unknown', policySource: 'default', purpose };
194
+ }
195
+
196
+ const provider = _providerOf(endpoint);
197
+
198
+ // Blunt, ops-friendly kill switch — same shape as the existing
199
+ // AGENTIC_SECURITY_LLM_VALIDATE=0 precedent in llm-validator/index.js.
200
+ if (process.env.AGENTIC_SECURITY_EGRESS_DENY === '1') {
201
+ return { allowed: false, decision: 'deny', reason: "AGENTIC_SECURITY_EGRESS_DENY=1 is set", provider, policySource: 'env', purpose };
202
+ }
203
+
204
+ const envMode = process.env.AGENTIC_SECURITY_EGRESS_MODE;
205
+ const cfg = loadPolicyConfig(scanRoot);
206
+ const mode = envMode || (cfg && cfg.mode) || 'allow';
207
+ const policySource = envMode ? 'env' : ((cfg && cfg.mode) ? 'config' : 'default');
208
+
209
+ if (mode === 'deny') {
210
+ return { allowed: false, decision: 'deny', reason: "egress mode is 'deny'", provider, policySource, purpose };
211
+ }
212
+
213
+ if (mode === 'local-only' && !isLoopbackUrl(endpoint)) {
214
+ return {
215
+ allowed: false, decision: 'deny',
216
+ reason: `egress mode is 'local-only' and the endpoint is not a loopback address`,
217
+ provider, policySource, purpose,
218
+ };
219
+ }
220
+
221
+ const deniedProviders = (cfg && Array.isArray(cfg.deniedProviders)) ? cfg.deniedProviders : [];
222
+ if (deniedProviders.includes(provider)) {
223
+ return { allowed: false, decision: 'deny', reason: `provider '${provider}' is in deniedProviders`, provider, policySource: 'config', purpose };
224
+ }
225
+
226
+ const allowedProviders = (cfg && Array.isArray(cfg.allowedProviders)) ? cfg.allowedProviders : null;
227
+ if (allowedProviders && !allowedProviders.includes(provider)) {
228
+ return { allowed: false, decision: 'deny', reason: `provider '${provider}' is not in allowedProviders`, provider, policySource: 'config', purpose };
229
+ }
230
+
231
+ // FR-602: the remaining list-membership dimensions (model/role/region/
232
+ // repository/dataClass), each a no-op unless BOTH the caller supplied a
233
+ // value AND the config restricts that specific dimension.
234
+ const ctxValues = { model, role, region, repository, dataClass };
235
+ for (const { ctxKey, allowKey, denyKey } of LIST_DIMENSIONS) {
236
+ const value = ctxValues[ctxKey];
237
+ if (value == null) continue;
238
+ const deniedList = (cfg && Array.isArray(cfg[denyKey])) ? cfg[denyKey] : [];
239
+ if (deniedList.includes(value)) {
240
+ return { allowed: false, decision: 'deny', reason: `${ctxKey} '${value}' is in ${denyKey}`, provider, policySource: 'config', purpose };
241
+ }
242
+ const allowedList = (cfg && Array.isArray(cfg[allowKey])) ? cfg[allowKey] : null;
243
+ if (allowedList && !allowedList.includes(value)) {
244
+ return { allowed: false, decision: 'deny', reason: `${ctxKey} '${value}' is not in ${allowKey}`, provider, policySource: 'config', purpose };
245
+ }
246
+ }
247
+
248
+ // FR-602: path is glob-matched, not list-membership — a single literal
249
+ // string is the wrong shape for a file path constraint.
250
+ if (filePath != null) {
251
+ const deniedPaths = (cfg && Array.isArray(cfg.deniedPaths)) ? cfg.deniedPaths : [];
252
+ if (matchesAnyGlob(filePath, deniedPaths)) {
253
+ return { allowed: false, decision: 'deny', reason: `path '${filePath}' matches a deniedPaths pattern`, provider, policySource: 'config', purpose };
254
+ }
255
+ const allowedPaths = (cfg && Array.isArray(cfg.allowedPaths)) ? cfg.allowedPaths : null;
256
+ if (allowedPaths && !matchesAnyGlob(filePath, allowedPaths)) {
257
+ return { allowed: false, decision: 'deny', reason: `path '${filePath}' does not match any allowedPaths pattern`, provider, policySource: 'config', purpose };
258
+ }
259
+ }
260
+
261
+ // FR-602: max-context is a numeric cap, evaluated only when the caller
262
+ // supplied its own token estimate AND the config sets a cap.
263
+ if (typeof contextTokens === 'number' && cfg && typeof cfg.maxContextTokens === 'number') {
264
+ if (contextTokens > cfg.maxContextTokens) {
265
+ return {
266
+ allowed: false, decision: 'deny',
267
+ reason: `estimated context (${contextTokens} tokens) exceeds maxContextTokens (${cfg.maxContextTokens})`,
268
+ provider, policySource: 'config', purpose,
269
+ };
270
+ }
271
+ }
272
+
273
+ // FR-607: a regulated profile can require APPROVED-PROVIDER metadata
274
+ // (DPA/BAA status, retention policy) before a provider is usable at all —
275
+ // a no-op unless the operator opts in via regulatedProfile.requireApproved
276
+ // Providers, same "restricts nothing until configured" default every
277
+ // other dimension above follows.
278
+ const regulatedProfile = (cfg && typeof cfg.regulatedProfile === 'object' && cfg.regulatedProfile) ? cfg.regulatedProfile : null;
279
+ let approvedProviderMetadata = null;
280
+ if (regulatedProfile && regulatedProfile.requireApprovedProviders) {
281
+ const requiredAttributes = (Array.isArray(regulatedProfile.requiredAttributes) && regulatedProfile.requiredAttributes.length)
282
+ ? regulatedProfile.requiredAttributes
283
+ : DEFAULT_REQUIRED_PROVIDER_ATTRIBUTES;
284
+ const approvedProviders = (cfg && typeof cfg.approvedProviders === 'object' && cfg.approvedProviders) ? cfg.approvedProviders : {};
285
+ const entry = approvedProviders[provider];
286
+ if (!entry || typeof entry !== 'object') {
287
+ return {
288
+ allowed: false, decision: 'deny',
289
+ reason: `provider '${provider}' has no approved-provider metadata configured, and this profile requires one (regulatedProfile.requireApprovedProviders)`,
290
+ provider, policySource: 'config', purpose,
291
+ };
292
+ }
293
+ const missing = requiredAttributes.filter((attr) => {
294
+ const v = entry[attr];
295
+ return !(typeof v === 'string' && v.length > 0 && v !== 'none' && v !== 'not_signed');
296
+ });
297
+ if (missing.length) {
298
+ return {
299
+ allowed: false, decision: 'deny',
300
+ reason: `provider '${provider}' is missing required approved-provider attribute(s): ${missing.join(', ')}`,
301
+ provider, policySource: 'config', purpose,
302
+ };
303
+ }
304
+ approvedProviderMetadata = entry;
305
+ }
306
+
307
+ return {
308
+ allowed: true, decision: 'allow', reason: null, provider, policySource, purpose,
309
+ ...(approvedProviderMetadata ? { approvedProviderMetadata } : {}),
310
+ };
311
+ }
312
+
313
+ export const _internals = { loadPolicyConfig, _providerOf, POLICY_FILE, DEFAULT_REQUIRED_PROVIDER_ATTRIBUTES };
@@ -0,0 +1,180 @@
1
+ // Outbound-payload content redaction (assurance-hardening PRD FR-603).
2
+ //
3
+ // policy.js decides WHETHER a call is allowed to leave the machine at all.
4
+ // This module decides WHAT is allowed to be IN the payload once a call is
5
+ // already permitted — the content-level companion to that call-level gate.
6
+ // Both read the same operator config (egress-policy.yml) via
7
+ // policy.js's exported loadPolicyConfig, so there is one config surface
8
+ // for the whole egress epic rather than a second file for redaction.
9
+ //
10
+ // Four categories, applied in a fixed order so an earlier, more specific
11
+ // pass never fights a later, more general one over the same span (mirrors
12
+ // llm-validator/redact.js's own most-specific-first rationale):
13
+ //
14
+ // 1. Proprietary paths — an operator-listed glob (`proprietaryPaths`)
15
+ // whose CONTENT must never leave at all, matched via util/glob.js's
16
+ // matchesAnyGlob (reused, not reimplemented — the same matcher FR-602
17
+ // uses for egress-policy.yml's deniedPaths/allowedPaths). Whole-span
18
+ // replacement, not a substring redaction, and it short-circuits every
19
+ // other pass — there is nothing left to redact once the whole thing
20
+ // is gone.
21
+ // 2. Secrets — reuses llm-validator/redact.js's redactSecrets VERBATIM
22
+ // (the proven, independently-tested implementation described there;
23
+ // not reimplemented here). That module's own header already noted
24
+ // this was "already partially done ... for its own path only" before
25
+ // this cycle — this module is what makes it reachable beyond that one
26
+ // caller as a named, general capability.
27
+ // 3. PII/PHI/PCI/FIN — reuses dataflow/privacy-taxonomy.js's field-name
28
+ // vocabulary (FR-402) as the single source of truth for "what counts
29
+ // as PII" across the codebase, applied with the same
30
+ // `name(:|=)"value"` shape llm-validator/redact.js's KEY_VALUE_RE
31
+ // already proved works for secrets. The CREDENTIALS class is skipped
32
+ // here — pass 2 already owns that category via redactSecrets, and
33
+ // redacting it twice under two different placeholder names would be
34
+ // confusing without adding any actual protection.
35
+ // 4. Customer data — organization-defined only. egress-policy.yml's
36
+ // `customerDataPatterns` (raw regex strings, operator-authored). No
37
+ // built-in default: unlike PII, "customer data" has no generic shape
38
+ // the harness could safely guess (an internal account-number format,
39
+ // a tenant-naming scheme, ...) — this category is a no-op until an
40
+ // operator configures it, by design.
41
+ //
42
+ // Returns { text, redactions, categories: {...} } so a caller can report
43
+ // HOW MUCH was removed per category (FR-604's audit metadata wants exactly
44
+ // this: counts, not content) without retaining what was actually removed.
45
+
46
+ import { loadPolicyConfig } from './policy.js';
47
+ import { redactSecrets } from '../llm-validator/redact.js';
48
+ import { loadPrivacyTaxonomy } from '../dataflow/privacy-taxonomy.js';
49
+ import { matchesAnyGlob } from '../util/glob.js';
50
+
51
+ const PROPRIETARY_PLACEHOLDER = '[REDACTED-PROPRIETARY-CONTENT]';
52
+ const PII_PLACEHOLDER = '[REDACTED-PII]';
53
+ const CUSTOMER_DATA_PLACEHOLDER = '[REDACTED-CUSTOMER-DATA]';
54
+
55
+ /**
56
+ * Build one `name(:|=)"value"` regex per taxonomy class (excluding
57
+ * CREDENTIALS — see module header). Mirrors llm-validator/redact.js's
58
+ * KEY_VALUE_RE shape exactly, generalized to an arbitrary field-name
59
+ * pattern list instead of a fixed word list.
60
+ */
61
+ function _buildPiiKeyValueRegexes(taxonomy) {
62
+ const regexes = [];
63
+ for (const [cls, def] of Object.entries(taxonomy || {})) {
64
+ if (cls === 'CREDENTIALS') continue;
65
+ const patterns = Array.isArray(def?.patterns) ? def.patterns : [];
66
+ if (!patterns.length) continue;
67
+ let re;
68
+ try {
69
+ // Named groups, not positional (\1/\2/\3) — several taxonomy field
70
+ // patterns carry their OWN capturing group (e.g. PII's
71
+ // `email([_-]?address)?`), which would silently shift every
72
+ // positional backreference below it. \k<quote> sidesteps that
73
+ // entirely regardless of how many groups the alternation contains.
74
+ re = new RegExp(
75
+ '(?<keyName>' + patterns.join('|') + ')(?<opWs>\\s*[:=]\\s*)(?<quote>[\'"`])(?<value>[^\'"`]+)\\k<quote>',
76
+ 'gi'
77
+ );
78
+ } catch {
79
+ continue; // an operator-supplied pattern (privacy-taxonomy.json) can be invalid regex
80
+ }
81
+ regexes.push(re);
82
+ }
83
+ return regexes;
84
+ }
85
+
86
+ /**
87
+ * Redact PII/PHI/PCI/FIN-shaped `key: "value"` / `key = "value"` spans.
88
+ * taxonomy defaults to the built-in DEFAULT_TAXONOMY when not supplied.
89
+ */
90
+ export function redactPii(text, { taxonomy } = {}) {
91
+ if (typeof text !== 'string' || text.length === 0) {
92
+ return { text: typeof text === 'string' ? text : '', redactions: 0 };
93
+ }
94
+ const effectiveTaxonomy = taxonomy || loadPrivacyTaxonomy(null).taxonomy;
95
+ let out = text;
96
+ let redactions = 0;
97
+ for (const re of _buildPiiKeyValueRegexes(effectiveTaxonomy)) {
98
+ out = out.replace(re, (...args) => {
99
+ const groups = args[args.length - 1];
100
+ redactions++;
101
+ return `${groups.keyName}${groups.opWs}${groups.quote}${PII_PLACEHOLDER}${groups.quote}`;
102
+ });
103
+ }
104
+ return { text: out, redactions };
105
+ }
106
+
107
+ /** Redact organization-defined customer-data patterns. No-op with no patterns configured. */
108
+ export function redactCustomerData(text, { patterns = [] } = {}) {
109
+ if (typeof text !== 'string' || text.length === 0 || !patterns.length) {
110
+ return { text: typeof text === 'string' ? text : '', redactions: 0 };
111
+ }
112
+ let out = text;
113
+ let redactions = 0;
114
+ for (const p of patterns) {
115
+ let re;
116
+ try { re = new RegExp(p, 'gi'); } catch { continue; } // invalid operator-supplied regex — skip, don't throw
117
+ out = out.replace(re, () => { redactions++; return CUSTOMER_DATA_PLACEHOLDER; });
118
+ }
119
+ return { text: out, redactions };
120
+ }
121
+
122
+ /** True when filePath matches an operator-configured proprietary-path glob. */
123
+ export function isProprietaryPath(filePath, { proprietaryPaths = [] } = {}) {
124
+ if (!filePath || !proprietaryPaths.length) return false;
125
+ return matchesAnyGlob(filePath, proprietaryPaths);
126
+ }
127
+
128
+ /**
129
+ * Apply the full redaction pipeline to one span of outbound prompt text.
130
+ *
131
+ * @param {object} opts
132
+ * @param {string} opts.text - the text about to be sent to a model endpoint.
133
+ * @param {string} [opts.filePath] - the source file this text was drawn
134
+ * from, if any — evaluated against `proprietaryPaths`.
135
+ * @param {string} [opts.scanRoot] - project root, for reading
136
+ * egress-policy.yml when `policy` is not already supplied.
137
+ * @param {object} [opts.taxonomy] - a pre-loaded privacy taxonomy (avoids
138
+ * re-reading privacy-taxonomy.json per call in a hot loop); defaults to
139
+ * loadPrivacyTaxonomy(scanRoot).taxonomy.
140
+ * @param {object} [opts.policy] - a pre-loaded egress-policy.yml document
141
+ * (avoids re-reading it per call); defaults to loadPolicyConfig(scanRoot).
142
+ * @returns {{text: string, redactions: number, categories: {proprietaryPath: number, secrets: number, pii: number, customerData: number}}}
143
+ */
144
+ export function redactPayload({ text, filePath = null, scanRoot = null, taxonomy = null, policy = null } = {}) {
145
+ const categories = { proprietaryPath: 0, secrets: 0, pii: 0, customerData: 0 };
146
+ if (typeof text !== 'string' || text.length === 0) {
147
+ return { text: typeof text === 'string' ? text : '', redactions: 0, categories };
148
+ }
149
+
150
+ const cfg = policy || loadPolicyConfig(scanRoot) || {};
151
+ const proprietaryPaths = Array.isArray(cfg.proprietaryPaths) ? cfg.proprietaryPaths : [];
152
+ if (isProprietaryPath(filePath, { proprietaryPaths })) {
153
+ categories.proprietaryPath = 1;
154
+ return { text: PROPRIETARY_PLACEHOLDER, redactions: 1, categories };
155
+ }
156
+
157
+ let out = text;
158
+
159
+ const secretResult = redactSecrets(out);
160
+ out = secretResult.text;
161
+ categories.secrets = secretResult.redactions;
162
+
163
+ const piiEnabled = cfg.redactPii !== false; // default ON — this category has a safe built-in default, unlike customerDataPatterns
164
+ if (piiEnabled) {
165
+ const effectiveTaxonomy = taxonomy || loadPrivacyTaxonomy(scanRoot).taxonomy;
166
+ const piiResult = redactPii(out, { taxonomy: effectiveTaxonomy });
167
+ out = piiResult.text;
168
+ categories.pii = piiResult.redactions;
169
+ }
170
+
171
+ const customerDataPatterns = Array.isArray(cfg.customerDataPatterns) ? cfg.customerDataPatterns : [];
172
+ const customerResult = redactCustomerData(out, { patterns: customerDataPatterns });
173
+ out = customerResult.text;
174
+ categories.customerData = customerResult.redactions;
175
+
176
+ const redactions = categories.proprietaryPath + categories.secrets + categories.pii + categories.customerData;
177
+ return { text: out, redactions, categories };
178
+ }
179
+
180
+ export const _internals = { _buildPiiKeyValueRegexes, PROPRIETARY_PLACEHOLDER, PII_PLACEHOLDER, CUSTOMER_DATA_PLACEHOLDER };