@tuent/sentinel 0.1.2 → 0.1.4

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.
@@ -1,15 +1,62 @@
1
- // src/repoSensitivityMap.ts
1
+ import {
2
+ DEFAULT_DANGEROUS_SCHEMES,
3
+ DEFAULT_FORBIDDEN_PATTERNS,
4
+ DEFAULT_NETWORK_DENYLIST_CIDRS,
5
+ checkSaneNumber,
6
+ suggestKey
7
+ } from "./chunk-KWZ7JKKO.js";
8
+
9
+ // src/repoSensitivityOverlay.ts
2
10
  import * as fs from "fs/promises";
3
11
  import * as path from "path";
4
12
  import * as os from "os";
5
- var DEFAULT_MAP_PATH = path.join(os.homedir(), ".dahlia", "repo-sensitivity.json");
6
- async function saveMap(map, customPath) {
7
- const target = customPath ?? DEFAULT_MAP_PATH;
13
+ var VALID_DECISION_TYPES = /* @__PURE__ */ new Set(["reject", "accept", "override"]);
14
+ var OVERLAY_TOP_KEYS = ["version", "repoRoot", "decisions", "updatedAt"];
15
+ var DECISION_KEYS = [
16
+ "path",
17
+ "decision",
18
+ "reason",
19
+ "sensitivity",
20
+ "category",
21
+ "decidedAt"
22
+ ];
23
+ function sanitizeSensitivityScore(fieldLabel, value, throwError) {
24
+ const violation = checkSaneNumber(value, {});
25
+ if (violation) {
26
+ throwError(`${fieldLabel} ${violation} \u2014 sensitivity must be a finite number in [0, 1]`);
27
+ }
28
+ const n = value;
29
+ if (n < 0 || n > 1) {
30
+ const clamped = Math.min(1, Math.max(0, n));
31
+ console.warn(
32
+ `[Sentinel] ${fieldLabel} is ${n}, outside [0, 1] \u2014 clamped to ${clamped}. Fix the file to silence this warning.`
33
+ );
34
+ return clamped;
35
+ }
36
+ return n;
37
+ }
38
+ function rejectUnknownJsonKeys(section, obj, allowed, throwError) {
39
+ for (const key of Object.keys(obj)) {
40
+ if (!allowed.includes(key)) {
41
+ const suggestion = suggestKey(key, allowed);
42
+ throwError(
43
+ `unknown key "${key}" in ${section}.` + (suggestion ? ` Did you mean "${suggestion}"?` : "") + ` Valid keys: ${allowed.join(", ")}`
44
+ );
45
+ }
46
+ }
47
+ }
48
+ var DEFAULT_OVERLAY_PATH = path.join(
49
+ os.homedir(),
50
+ ".dahlia",
51
+ "repo-sensitivity.review.json"
52
+ );
53
+ async function saveOverlay(overlay, customPath) {
54
+ const target = customPath ?? DEFAULT_OVERLAY_PATH;
8
55
  const parent = path.dirname(target);
9
56
  const tempPath = target + ".tmp";
10
57
  try {
11
58
  await fs.mkdir(parent, { recursive: true });
12
- await fs.writeFile(tempPath, JSON.stringify(map, null, 2), "utf-8");
59
+ await fs.writeFile(tempPath, JSON.stringify(overlay, null, 2), "utf-8");
13
60
  await fs.rename(tempPath, target);
14
61
  } catch (err) {
15
62
  try {
@@ -17,13 +64,16 @@ async function saveMap(map, customPath) {
17
64
  } catch {
18
65
  }
19
66
  const message = err instanceof Error ? err.message : String(err);
20
- throw new Error(`RepoSensitivityMap.saveMap: failed to save to ${target}: ${message}`, {
67
+ throw new Error(`SensitivityOverlay.saveOverlay: failed to save to ${target}: ${message}`, {
21
68
  cause: err
22
69
  });
23
70
  }
24
71
  }
25
- async function loadMap(customPath) {
26
- const target = customPath ?? DEFAULT_MAP_PATH;
72
+ async function loadOverlay(customPath) {
73
+ const target = customPath ?? DEFAULT_OVERLAY_PATH;
74
+ const failLoad = (message) => {
75
+ throw new Error(`SensitivityOverlay.loadOverlay: file at ${target} is invalid: ${message}`);
76
+ };
27
77
  let raw;
28
78
  try {
29
79
  raw = await fs.readFile(target, "utf-8");
@@ -38,21 +88,56 @@ async function loadMap(customPath) {
38
88
  parsed = JSON.parse(raw);
39
89
  } catch (err) {
40
90
  const message = err instanceof Error ? err.message : String(err);
41
- throw new Error(`RepoSensitivityMap.loadMap: file at ${target} is malformed: ${message}`, {
91
+ throw new Error(`SensitivityOverlay.loadOverlay: file at ${target} is malformed: ${message}`, {
42
92
  cause: err
43
93
  });
44
94
  }
45
- const scannedAt = typeof parsed.scannedAt === "string" ? parsed.scannedAt : "unknown";
95
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
96
+ failLoad("top level must be a JSON object");
97
+ }
98
+ rejectUnknownJsonKeys("overlay", parsed, OVERLAY_TOP_KEYS, failLoad);
99
+ const version = typeof parsed.version === "string" ? parsed.version : "1.0";
46
100
  const repoRoot = typeof parsed.repoRoot === "string" ? parsed.repoRoot : "";
47
- const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
48
- const summary = parsed.summary && typeof parsed.summary === "object" && !Array.isArray(parsed.summary) ? parsed.summary : computeSummaryFromEntries(entries);
49
- return { scannedAt, repoRoot, entries, summary };
101
+ const rawDecisions = Array.isArray(parsed.decisions) ? parsed.decisions : [];
102
+ const updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : "unknown";
103
+ const decisions = rawDecisions.map((d, i) => {
104
+ const prefix = `decisions[${i}]`;
105
+ if (typeof d !== "object" || d === null || Array.isArray(d)) {
106
+ failLoad(`${prefix} must be an object`);
107
+ }
108
+ const entry = d;
109
+ rejectUnknownJsonKeys(prefix, entry, DECISION_KEYS, failLoad);
110
+ if (typeof entry.path !== "string" || entry.path.length === 0) {
111
+ failLoad(`${prefix}.path must be a non-empty string`);
112
+ }
113
+ if (typeof entry.decision !== "string" || !VALID_DECISION_TYPES.has(entry.decision)) {
114
+ failLoad(
115
+ `${prefix}.decision is "${String(entry.decision)}" \u2014 must be one of: reject, accept, override`
116
+ );
117
+ }
118
+ const result = {
119
+ path: entry.path,
120
+ decision: entry.decision,
121
+ decidedAt: typeof entry.decidedAt === "string" ? entry.decidedAt : "unknown"
122
+ };
123
+ if (entry.reason !== void 0) result.reason = String(entry.reason);
124
+ if (entry.category !== void 0) result.category = String(entry.category);
125
+ if (entry.sensitivity !== void 0) {
126
+ result.sensitivity = sanitizeSensitivityScore(
127
+ `${prefix}.sensitivity`,
128
+ entry.sensitivity,
129
+ failLoad
130
+ );
131
+ }
132
+ return result;
133
+ });
134
+ return { version, repoRoot, decisions, updatedAt };
50
135
  }
51
- function lookupEntry(map, target, repoRoot) {
136
+ function lookupOverlayDecision(overlay, target, repoRoot) {
52
137
  if (!target || typeof target !== "string" || target.trim() === "") {
53
138
  return null;
54
139
  }
55
- const lookupRoot = repoRoot ?? map.repoRoot;
140
+ const lookupRoot = repoRoot ?? overlay.repoRoot;
56
141
  let canonical;
57
142
  if (path.isAbsolute(target)) {
58
143
  if (!lookupRoot) return null;
@@ -66,48 +151,76 @@ function lookupEntry(map, target, repoRoot) {
66
151
  canonical = canonical.slice(2);
67
152
  }
68
153
  canonical = canonical.replace(/\\/g, "/");
69
- for (const entry of map.entries) {
70
- if (entry.path === canonical) return entry;
154
+ for (const decision of overlay.decisions) {
155
+ if (decision.path === canonical) return decision;
71
156
  }
72
157
  return null;
73
158
  }
74
- function computeSummaryFromEntries(entries) {
75
- const byCategory = {};
76
- const byConfidence = {
77
- low: 0,
78
- medium: 0,
79
- high: 0
159
+ function applyOverlay(overlay, decisionPath, decisionType, options) {
160
+ let canonical = decisionPath;
161
+ if (canonical.startsWith("./")) {
162
+ canonical = canonical.slice(2);
163
+ }
164
+ canonical = canonical.replace(/\\/g, "/");
165
+ const newDecision = {
166
+ path: canonical,
167
+ decision: decisionType,
168
+ decidedAt: (/* @__PURE__ */ new Date()).toISOString()
80
169
  };
81
- for (const entry of entries) {
82
- byCategory[entry.category] = (byCategory[entry.category] ?? 0) + 1;
83
- if (entry.confidence === "low" || entry.confidence === "medium" || entry.confidence === "high") {
84
- byConfidence[entry.confidence]++;
85
- }
170
+ if (options?.reason !== void 0) newDecision.reason = options.reason;
171
+ if (options?.sensitivity !== void 0) newDecision.sensitivity = options.sensitivity;
172
+ if (options?.category !== void 0) newDecision.category = options.category;
173
+ const existing = overlay.decisions.findIndex((d) => d.path === canonical);
174
+ const decisions = existing >= 0 ? [
175
+ ...overlay.decisions.slice(0, existing),
176
+ newDecision,
177
+ ...overlay.decisions.slice(existing + 1)
178
+ ] : [...overlay.decisions, newDecision];
179
+ return {
180
+ ...overlay,
181
+ decisions,
182
+ updatedAt: newDecision.decidedAt
183
+ };
184
+ }
185
+ function removeOverlayDecision(overlay, decisionPath) {
186
+ let canonical = decisionPath;
187
+ if (canonical.startsWith("./")) {
188
+ canonical = canonical.slice(2);
189
+ }
190
+ canonical = canonical.replace(/\\/g, "/");
191
+ const filtered = overlay.decisions.filter((d) => d.path !== canonical);
192
+ if (filtered.length === overlay.decisions.length) {
193
+ return overlay;
86
194
  }
87
195
  return {
88
- totalFiles: entries.length,
89
- sensitiveFiles: entries.length,
90
- byCategory,
91
- byConfidence
196
+ ...overlay,
197
+ decisions: filtered,
198
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
199
+ };
200
+ }
201
+ function createEmptyOverlay(repoRoot) {
202
+ return {
203
+ version: "1.0",
204
+ repoRoot,
205
+ decisions: [],
206
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
92
207
  };
93
208
  }
94
209
 
95
- // src/repoSensitivityOverlay.ts
210
+ // src/repoSensitivityMap.ts
96
211
  import * as fs2 from "fs/promises";
97
212
  import * as path2 from "path";
98
213
  import * as os2 from "os";
99
- var DEFAULT_OVERLAY_PATH = path2.join(
100
- os2.homedir(),
101
- ".dahlia",
102
- "repo-sensitivity.review.json"
103
- );
104
- async function saveOverlay(overlay, customPath) {
105
- const target = customPath ?? DEFAULT_OVERLAY_PATH;
214
+ var MAP_TOP_KEYS = ["scannedAt", "repoRoot", "entries", "summary"];
215
+ var ENTRY_KEYS = ["path", "sensitivity", "category", "confidence", "matchedRules"];
216
+ var DEFAULT_MAP_PATH = path2.join(os2.homedir(), ".dahlia", "repo-sensitivity.json");
217
+ async function saveMap(map, customPath) {
218
+ const target = customPath ?? DEFAULT_MAP_PATH;
106
219
  const parent = path2.dirname(target);
107
220
  const tempPath = target + ".tmp";
108
221
  try {
109
222
  await fs2.mkdir(parent, { recursive: true });
110
- await fs2.writeFile(tempPath, JSON.stringify(overlay, null, 2), "utf-8");
223
+ await fs2.writeFile(tempPath, JSON.stringify(map, null, 2), "utf-8");
111
224
  await fs2.rename(tempPath, target);
112
225
  } catch (err) {
113
226
  try {
@@ -115,13 +228,16 @@ async function saveOverlay(overlay, customPath) {
115
228
  } catch {
116
229
  }
117
230
  const message = err instanceof Error ? err.message : String(err);
118
- throw new Error(`SensitivityOverlay.saveOverlay: failed to save to ${target}: ${message}`, {
231
+ throw new Error(`RepoSensitivityMap.saveMap: failed to save to ${target}: ${message}`, {
119
232
  cause: err
120
233
  });
121
234
  }
122
235
  }
123
- async function loadOverlay(customPath) {
124
- const target = customPath ?? DEFAULT_OVERLAY_PATH;
236
+ async function loadMap(customPath) {
237
+ const target = customPath ?? DEFAULT_MAP_PATH;
238
+ const failLoad = (message) => {
239
+ throw new Error(`RepoSensitivityMap.loadMap: file at ${target} is invalid: ${message}`);
240
+ };
125
241
  let raw;
126
242
  try {
127
243
  raw = await fs2.readFile(target, "utf-8");
@@ -136,21 +252,48 @@ async function loadOverlay(customPath) {
136
252
  parsed = JSON.parse(raw);
137
253
  } catch (err) {
138
254
  const message = err instanceof Error ? err.message : String(err);
139
- throw new Error(`SensitivityOverlay.loadOverlay: file at ${target} is malformed: ${message}`, {
255
+ throw new Error(`RepoSensitivityMap.loadMap: file at ${target} is malformed: ${message}`, {
140
256
  cause: err
141
257
  });
142
258
  }
143
- const version = typeof parsed.version === "string" ? parsed.version : "1.0";
259
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
260
+ failLoad("top level must be a JSON object");
261
+ }
262
+ rejectUnknownJsonKeys("map", parsed, MAP_TOP_KEYS, failLoad);
263
+ const scannedAt = typeof parsed.scannedAt === "string" ? parsed.scannedAt : "unknown";
144
264
  const repoRoot = typeof parsed.repoRoot === "string" ? parsed.repoRoot : "";
145
- const decisions = Array.isArray(parsed.decisions) ? parsed.decisions : [];
146
- const updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : "unknown";
147
- return { version, repoRoot, decisions, updatedAt };
265
+ const rawEntries = Array.isArray(parsed.entries) ? parsed.entries : [];
266
+ const entries = rawEntries.map((e, i) => {
267
+ const prefix = `entries[${i}]`;
268
+ if (typeof e !== "object" || e === null || Array.isArray(e)) {
269
+ failLoad(`${prefix} must be an object`);
270
+ }
271
+ const entry = e;
272
+ rejectUnknownJsonKeys(prefix, entry, ENTRY_KEYS, failLoad);
273
+ if (typeof entry.path !== "string" || entry.path.length === 0) {
274
+ failLoad(`${prefix}.path must be a non-empty string`);
275
+ }
276
+ const sensitivity = sanitizeSensitivityScore(
277
+ `${prefix}.sensitivity`,
278
+ entry.sensitivity,
279
+ failLoad
280
+ );
281
+ return {
282
+ path: entry.path,
283
+ sensitivity,
284
+ category: typeof entry.category === "string" ? entry.category : "general",
285
+ confidence: entry.confidence === "low" || entry.confidence === "medium" || entry.confidence === "high" ? entry.confidence : "low",
286
+ matchedRules: Array.isArray(entry.matchedRules) ? entry.matchedRules.map(String) : []
287
+ };
288
+ });
289
+ const summary = parsed.summary && typeof parsed.summary === "object" && !Array.isArray(parsed.summary) ? parsed.summary : computeSummaryFromEntries(entries);
290
+ return { scannedAt, repoRoot, entries, summary };
148
291
  }
149
- function lookupOverlayDecision(overlay, target, repoRoot) {
292
+ function lookupEntry(map, target, repoRoot) {
150
293
  if (!target || typeof target !== "string" || target.trim() === "") {
151
294
  return null;
152
295
  }
153
- const lookupRoot = repoRoot ?? overlay.repoRoot;
296
+ const lookupRoot = repoRoot ?? map.repoRoot;
154
297
  let canonical;
155
298
  if (path2.isAbsolute(target)) {
156
299
  if (!lookupRoot) return null;
@@ -164,134 +307,32 @@ function lookupOverlayDecision(overlay, target, repoRoot) {
164
307
  canonical = canonical.slice(2);
165
308
  }
166
309
  canonical = canonical.replace(/\\/g, "/");
167
- for (const decision of overlay.decisions) {
168
- if (decision.path === canonical) return decision;
310
+ for (const entry of map.entries) {
311
+ if (entry.path === canonical) return entry;
169
312
  }
170
313
  return null;
171
314
  }
172
- function applyOverlay(overlay, decisionPath, decisionType, options) {
173
- let canonical = decisionPath;
174
- if (canonical.startsWith("./")) {
175
- canonical = canonical.slice(2);
176
- }
177
- canonical = canonical.replace(/\\/g, "/");
178
- const newDecision = {
179
- path: canonical,
180
- decision: decisionType,
181
- decidedAt: (/* @__PURE__ */ new Date()).toISOString()
182
- };
183
- if (options?.reason !== void 0) newDecision.reason = options.reason;
184
- if (options?.sensitivity !== void 0) newDecision.sensitivity = options.sensitivity;
185
- if (options?.category !== void 0) newDecision.category = options.category;
186
- const existing = overlay.decisions.findIndex((d) => d.path === canonical);
187
- const decisions = existing >= 0 ? [
188
- ...overlay.decisions.slice(0, existing),
189
- newDecision,
190
- ...overlay.decisions.slice(existing + 1)
191
- ] : [...overlay.decisions, newDecision];
192
- return {
193
- ...overlay,
194
- decisions,
195
- updatedAt: newDecision.decidedAt
315
+ function computeSummaryFromEntries(entries) {
316
+ const byCategory = {};
317
+ const byConfidence = {
318
+ low: 0,
319
+ medium: 0,
320
+ high: 0
196
321
  };
197
- }
198
- function removeOverlayDecision(overlay, decisionPath) {
199
- let canonical = decisionPath;
200
- if (canonical.startsWith("./")) {
201
- canonical = canonical.slice(2);
202
- }
203
- canonical = canonical.replace(/\\/g, "/");
204
- const filtered = overlay.decisions.filter((d) => d.path !== canonical);
205
- if (filtered.length === overlay.decisions.length) {
206
- return overlay;
322
+ for (const entry of entries) {
323
+ byCategory[entry.category] = (byCategory[entry.category] ?? 0) + 1;
324
+ if (entry.confidence === "low" || entry.confidence === "medium" || entry.confidence === "high") {
325
+ byConfidence[entry.confidence]++;
326
+ }
207
327
  }
208
328
  return {
209
- ...overlay,
210
- decisions: filtered,
211
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
212
- };
213
- }
214
- function createEmptyOverlay(repoRoot) {
215
- return {
216
- version: "1.0",
217
- repoRoot,
218
- decisions: [],
219
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
329
+ totalFiles: entries.length,
330
+ sensitiveFiles: entries.length,
331
+ byCategory,
332
+ byConfidence
220
333
  };
221
334
  }
222
335
 
223
- // src/defaults.ts
224
- var DEFAULT_FORBIDDEN_PATTERNS = [
225
- "**/.env",
226
- "**/.env.*",
227
- "**/.ssh/**",
228
- "**/.aws/**",
229
- "**/secrets/**",
230
- "**/credentials/**",
231
- "**/id_rsa*",
232
- "**/id_dsa*",
233
- "**/id_ecdsa*",
234
- "**/id_ed25519*",
235
- "**/*.pem",
236
- "**/*.key",
237
- "/etc/**",
238
- // Sprint 26 FIX 1 (A) — common credential stores. DRIFT: keep in sync with
239
- // STARTER_POLICY.forbid.targets in setup/initClaudeCode.ts (template↔defaults
240
- // unification tracked in a separate ticket — do not refactor here).
241
- "**/.netrc",
242
- "**/.npmrc",
243
- "**/.git-credentials",
244
- "**/.pgpass",
245
- "**/.zsh_history",
246
- "**/.config/gh/**",
247
- "**/.docker/config.json",
248
- "**/.gnupg/**",
249
- "**/.config/gcloud/**",
250
- "**/.kube/**",
251
- "**/Library/Keychains/**",
252
- // Sprint 26 FIX 1 (B) — Sentinel's own state dir (current path only; the
253
- // ~/.dahlia → ~/.sentinel rename is a separate re-arch).
254
- "**/.dahlia/**",
255
- // Sprint 26 FIX 3 — the live policy file and cc's hook-wiring settings files.
256
- // Denies the agent's own tool-writes (policy rewrite / unhook vectors); reads
257
- // stay allowed via DEFAULT_POLICY_READ_EXCEPTIONS below. The settings glob
258
- // covers project AND user-level (~/.claude/settings*.json) in one pattern.
259
- // DRIFT: keep in sync with STARTER_POLICY.forbid.targets in setup/initClaudeCode.ts.
260
- "**/.sentinel.yaml",
261
- "**/.claude/settings*.json"
262
- ];
263
- var DEFAULT_POLICY_READ_EXCEPTIONS = [
264
- { target: "**/.sentinel.yaml", allowedActions: ["file_read"] },
265
- { target: "**/.claude/settings*.json", allowedActions: ["file_read"] }
266
- ];
267
- function withPolicyReadExceptions(existing) {
268
- const merged = existing ? [...existing] : [];
269
- for (const exc of DEFAULT_POLICY_READ_EXCEPTIONS) {
270
- if (!merged.some((e) => e.target === exc.target)) merged.push(exc);
271
- }
272
- return merged;
273
- }
274
- var DEFAULT_MEDIUM_DISPOSITION = {
275
- network_request: "deny"
276
- };
277
- var DEFAULT_NETWORK_DENYLIST_CIDRS = [
278
- "10.0.0.0/8",
279
- // RFC1918 private (Class A)
280
- "172.16.0.0/12",
281
- // RFC1918 private (Class B)
282
- "192.168.0.0/16",
283
- // RFC1918 private (Class C)
284
- "127.0.0.0/8",
285
- // loopback v4
286
- "169.254.0.0/16",
287
- // link-local v4 (cloud metadata endpoints)
288
- "fe80::/10",
289
- // link-local v6
290
- "::1/128"
291
- // loopback v6
292
- ];
293
- var DEFAULT_DANGEROUS_SCHEMES = ["file:", "data:", "javascript:", "vbscript:"];
294
-
295
336
  // src/roleValidator.ts
296
337
  import { normalize as normalize2, basename as basename2, dirname as dirname4, join as join4 } from "path";
297
338
  import { lstatSync, readdirSync, realpathSync as realpathSync2 } from "fs";
@@ -550,7 +591,7 @@ function tokenizePaths(command) {
550
591
  if (dispatch.unparseable) {
551
592
  result.unparseable = true;
552
593
  }
553
- } else if (isPathShaped(token)) {
594
+ } else if (isPathShaped(token) && !isEnvIdentifierChain(token)) {
554
595
  const resolved = resolvePathToken(token);
555
596
  result.paths.push(resolved);
556
597
  }
@@ -558,6 +599,21 @@ function tokenizePaths(command) {
558
599
  }
559
600
  return result;
560
601
  }
602
+ function isEnvIdentifierChain(token) {
603
+ if (token.includes("/") || token.startsWith(".")) return false;
604
+ if (!allEnvOccurrencesIdentifierEmbedded(token)) return false;
605
+ return !SENSITIVE_BASENAME_RE.test(token.replace(/\.env/gi, " "));
606
+ }
607
+ function allEnvOccurrencesIdentifierEmbedded(s) {
608
+ const envHits = /\.env/gi;
609
+ let m;
610
+ let any = false;
611
+ while ((m = envHits.exec(s)) !== null) {
612
+ any = true;
613
+ if (!/[A-Za-z0-9_$]/.test(s[m.index - 1] ?? "")) return false;
614
+ }
615
+ return any;
616
+ }
561
617
  function isPathShaped(token) {
562
618
  if (token.includes("/")) return true;
563
619
  if (token.startsWith(".")) return true;
@@ -633,8 +689,8 @@ function scanBashCommand(command, forbiddenBasenames) {
633
689
  }
634
690
  function buildPattern(basename3) {
635
691
  const escaped = escapeRegex(basename3);
636
- if (basename3.startsWith(".") && basename3.length <= 4 && !isAlphaAfterDot(basename3)) {
637
- return new RegExp(`\\w${escaped}(?=$|[\\s;&|<>()'"=\\/])`, "i");
692
+ if (EXTENSION_BASENAMES.has(basename3.toLowerCase())) {
693
+ return new RegExp(`(?:^|\\w)${escaped}(?=$|[\\s;&|<>()'"=\\/])`, "i");
638
694
  }
639
695
  if (basename3.startsWith(".")) {
640
696
  return new RegExp(`(?:^|[\\s;&|<>()\\/'"=])${escaped}(?=$|[\\s;&|<>()\\/'"=.])`, "i");
@@ -653,17 +709,15 @@ function scanContentForForbiddenBasenames(content, forbiddenBasenames) {
653
709
  }
654
710
  function buildContentPattern(basename3) {
655
711
  const escaped = escapeRegex(basename3);
656
- if (basename3.startsWith(".") && basename3.length <= 4 && !isAlphaAfterDot(basename3)) {
657
- return new RegExp(`\\w${escaped}(?=$|[\\s;&|<>()'"=\\/])`, "i");
712
+ if (EXTENSION_BASENAMES.has(basename3.toLowerCase())) {
713
+ return new RegExp(`(?:^|\\w)${escaped}(?=$|[\\s;&|<>()'"=\\/])`, "i");
658
714
  }
659
715
  if (basename3.startsWith(".")) {
660
716
  return new RegExp(`(?:^|[\\s;&|<>()\\/'"=])${escaped}(?=$|[\\s;&|<>()\\/'"=.])`, "i");
661
717
  }
662
718
  return new RegExp(`(?<=[/\\\\]\\.?)${escaped}\\b`, "i");
663
719
  }
664
- function isAlphaAfterDot(s) {
665
- return /^\.[a-zA-Z]+$/.test(s);
666
- }
720
+ var EXTENSION_BASENAMES = /* @__PURE__ */ new Set([".pem", ".key"]);
667
721
  function escapeRegex(s) {
668
722
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
669
723
  }
@@ -681,8 +735,8 @@ function scanGlobPattern(pattern, forbiddenBasenames) {
681
735
  function buildGlobContextPattern(basename3) {
682
736
  const escaped = escapeRegex(basename3);
683
737
  const GLOB_DELIM = String.raw`\s;&|<>()\\/'"=.*?{}\[\]`;
684
- if (basename3.startsWith(".") && basename3.length <= 4 && !isAlphaAfterDot(basename3)) {
685
- return new RegExp(`[\\w*]${escaped}(?=$|[${GLOB_DELIM}])`, "i");
738
+ if (EXTENSION_BASENAMES.has(basename3.toLowerCase())) {
739
+ return new RegExp(`(?:^|[\\w*])${escaped}(?=$|[${GLOB_DELIM}])`, "i");
686
740
  }
687
741
  if (basename3.startsWith(".")) {
688
742
  return new RegExp(`(?:^|[${GLOB_DELIM}])${escaped}(?=$|[${GLOB_DELIM}])`, "i");
@@ -816,6 +870,7 @@ function classifyDeny(command, signals) {
816
870
  }
817
871
 
818
872
  // src/roleValidator.ts
873
+ import { parse as shellParse2 } from "shell-quote";
819
874
  var SUSPICIOUS_BASENAME_RE = /^\.|(\.env|secret|credential|key|config|token)/i;
820
875
  function resolveSymlinks(normalizedPath) {
821
876
  if (!normalizedPath || normalizedPath.includes("://")) return normalizedPath;
@@ -1148,10 +1203,26 @@ var RoleValidator = class {
1148
1203
  }
1149
1204
  }
1150
1205
  }
1151
- const safeCommandMention = event.action === "command_exec" && isPositionallySafeMention(event.primaryTarget);
1206
+ if (event.action === "command_exec") {
1207
+ const cmd = this.screenCommandTarget(event);
1208
+ if (cmd) {
1209
+ const finding = this.buildForbiddenFinding(
1210
+ event,
1211
+ cmd.matchedTarget,
1212
+ cmd.matchedPattern,
1213
+ activeTask ?? null
1214
+ );
1215
+ if (finding) {
1216
+ finding.targetKey = cmd.targetKey;
1217
+ finding.dedupKey = event.primaryTarget;
1218
+ finding.mentionOnly = false;
1219
+ return finding;
1220
+ }
1221
+ }
1222
+ }
1152
1223
  for (const pattern of this.role.forbiddenTargetPatterns) {
1153
1224
  let matchedTargetValue = null;
1154
- if (!safeCommandMention && primaryTargetPaths.some((tp) => matchGlobInsensitive(pattern, tp))) {
1225
+ if (event.action !== "command_exec" && primaryTargetPaths.some((tp) => matchGlobInsensitive(pattern, tp))) {
1155
1226
  matchedTargetValue = normalizedPrimaryTarget;
1156
1227
  }
1157
1228
  if (!matchedTargetValue) {
@@ -1169,57 +1240,13 @@ var RoleValidator = class {
1169
1240
  }
1170
1241
  }
1171
1242
  if (matchedTargetValue) {
1172
- const sensitivity = this.sensitivityScorer?.scoreTarget(matchedTargetValue, event.action);
1173
- const isCritical = sensitivity ? sensitivity.effectiveScore >= 0.9 : false;
1174
- const finding = this.makeFinding(event, {
1175
- severity: "HIGH",
1176
- type: "unauthorized_target",
1177
- description: `Agent accessed '${matchedTargetValue}' which matches forbidden pattern '${pattern}'`,
1178
- recommendation: getTargetRecommendation(
1179
- "unauthorized_target",
1180
- matchedTargetValue,
1181
- event.action
1182
- ),
1183
- matchedTarget: matchedTargetValue
1184
- });
1185
- if (!isCritical) {
1186
- const exception = findMatchingException(this.role.exceptions, event, activeTask ?? null);
1187
- if (exception) {
1188
- this.onAuditEntry?.({
1189
- type: "exception_applied",
1190
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1191
- agentId: event.agentId,
1192
- target: matchedTargetValue,
1193
- action: event.action,
1194
- exceptionTarget: exception.target,
1195
- taskId: activeTask?.taskId ?? null
1196
- });
1197
- if (exception.requiresApproval) {
1198
- const ctx = {
1199
- finding,
1200
- exception,
1201
- activeTask: activeTask ?? null,
1202
- expiresAt: exception.expiresAfter != null && activeTask ? new Date(new Date(activeTask.startedAt).getTime() + exception.expiresAfter) : null
1203
- };
1204
- const approved = this.approvalFn?.(ctx) === true;
1205
- if (approved) {
1206
- this.onAuditEntry?.({
1207
- type: "exception_approved",
1208
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1209
- agentId: event.agentId,
1210
- target: matchedTargetValue,
1211
- action: event.action,
1212
- exceptionTarget: exception.target,
1213
- taskId: activeTask?.taskId ?? null
1214
- });
1215
- continue;
1216
- }
1217
- } else {
1218
- continue;
1219
- }
1220
- }
1221
- }
1222
- return this.enhanceWithSensitivity(finding, event);
1243
+ const finding = this.buildForbiddenFinding(
1244
+ event,
1245
+ matchedTargetValue,
1246
+ pattern,
1247
+ activeTask ?? null
1248
+ );
1249
+ if (finding) return finding;
1223
1250
  }
1224
1251
  }
1225
1252
  if (this.sensitivityScorer && event.metadata?._mcpTool === "true") {
@@ -1238,7 +1265,7 @@ var RoleValidator = class {
1238
1265
  });
1239
1266
  }
1240
1267
  }
1241
- if (this.role.allowedTargetPatterns.length > 0) {
1268
+ if (this.role.allowedTargetPatterns.length > 0 && event.action !== "command_exec") {
1242
1269
  const anchor = this.workspaceRoot !== "" && PATH_TARGET_ACTIONS.has(event.action);
1243
1270
  const matched = this.role.allowedTargetPatterns.some((pattern) => {
1244
1271
  const candidates = anchor ? [pattern, anchorAllowedPattern(pattern, this.workspaceRoot)] : [pattern];
@@ -1276,7 +1303,7 @@ var RoleValidator = class {
1276
1303
  }
1277
1304
  }
1278
1305
  const scopePatterns = activeTask?.scopePatterns;
1279
- if (scopePatterns && scopePatterns.length > 0) {
1306
+ if (scopePatterns && scopePatterns.length > 0 && event.action !== "command_exec") {
1280
1307
  const anchor = this.workspaceRoot !== "" && PATH_TARGET_ACTIONS.has(event.action);
1281
1308
  const inScope = scopePatterns.some((pattern) => {
1282
1309
  const candidates = anchor ? [pattern, anchorAllowedPattern(pattern, this.workspaceRoot)] : [pattern];
@@ -1340,6 +1367,127 @@ var RoleValidator = class {
1340
1367
  }
1341
1368
  return finding;
1342
1369
  }
1370
+ /**
1371
+ * Sprint 26B B′ — scanner-authoritative forbidden screen for a command_exec
1372
+ * PRIMARY target (the command string). Three layers, first match wins:
1373
+ *
1374
+ * L1 — tokenizePaths(command): resolved path-shaped tokens (symlink-resolved,
1375
+ * dir-globs, F-5b's isEnvIdentifierChain guard inside) matched against the
1376
+ * FULL role patterns via matchGlob.
1377
+ * Boundary — every argv token's basename vs each pattern's basename component
1378
+ * via the SAME matchGlob (its `^…$` anchoring → true boundary, so
1379
+ * `^\.env$` rejects `process.env` and `^payroll\.csv$` rejects
1380
+ * `mypayroll.csv`, while a `payroll.csv` token matches a `payroll.csv`
1381
+ * pattern basename). Patterns whose basename is pure-wildcard (a bare
1382
+ * star or globstar) are dir-globs and are skipped here (L1 handles them
1383
+ * on resolved paths).
1384
+ * L2 — scanBashCommand substring net (fixed FORBIDDEN_BASENAMES) gated by
1385
+ * isPositionallySafeMention, for heredoc/substitution shapes tokenization
1386
+ * can't cleanly split.
1387
+ *
1388
+ * Returns the matched target + the F-5a targetKey (resolved path / token /
1389
+ * `l2:<basenames>`, mirroring the gateway emit) + a pattern label for the
1390
+ * finding description, or null when nothing matches.
1391
+ */
1392
+ screenCommandTarget(event) {
1393
+ const command = event.primaryTarget;
1394
+ if (isPositionallySafeMention(command)) return null;
1395
+ const tr = tokenizePaths(command);
1396
+ for (const p of tr.paths) {
1397
+ for (const pattern of this.role.forbiddenTargetPatterns) {
1398
+ if (matchGlobInsensitive(pattern, p)) {
1399
+ return { matchedTarget: p, targetKey: p, matchedPattern: pattern };
1400
+ }
1401
+ }
1402
+ }
1403
+ let tokens;
1404
+ try {
1405
+ tokens = shellParse2(command);
1406
+ } catch {
1407
+ tokens = [];
1408
+ }
1409
+ for (const tok of tokens) {
1410
+ if (typeof tok !== "string") continue;
1411
+ const tokBase = basename2(tok);
1412
+ if (tokBase.length === 0) continue;
1413
+ for (const pattern of this.role.forbiddenTargetPatterns) {
1414
+ const patBase = basename2(pattern);
1415
+ if (patBase === "*" || patBase === "**") continue;
1416
+ if (matchGlobInsensitive(patBase, tokBase)) {
1417
+ return { matchedTarget: tok, targetKey: tok, matchedPattern: pattern };
1418
+ }
1419
+ }
1420
+ }
1421
+ const scan = scanBashCommand(command, FORBIDDEN_BASENAMES);
1422
+ if (scan.matched) {
1423
+ const hits = [...new Set(scan.hits)].sort();
1424
+ return {
1425
+ matchedTarget: scan.hits[0],
1426
+ targetKey: `l2:${hits.join(",")}`,
1427
+ matchedPattern: hits.join(", ")
1428
+ };
1429
+ }
1430
+ return null;
1431
+ }
1432
+ /**
1433
+ * Build the unauthorized_target finding for a matched forbidden target, applying
1434
+ * the same exception/approval/sensitivity flow shared by Check 2's per-pattern
1435
+ * loop and the B′ command screen. Returns the finding to emit, or null when an
1436
+ * exception (auto, or approved) suppresses it (caller continues / falls through).
1437
+ */
1438
+ buildForbiddenFinding(event, matchedTargetValue, pattern, activeTask) {
1439
+ const sensitivity = this.sensitivityScorer?.scoreTarget(matchedTargetValue, event.action);
1440
+ const isCritical = sensitivity ? sensitivity.effectiveScore >= 0.9 : false;
1441
+ const finding = this.makeFinding(event, {
1442
+ severity: "HIGH",
1443
+ type: "unauthorized_target",
1444
+ description: `Agent accessed '${matchedTargetValue}' which matches forbidden pattern '${pattern}'`,
1445
+ recommendation: getTargetRecommendation(
1446
+ "unauthorized_target",
1447
+ matchedTargetValue,
1448
+ event.action
1449
+ ),
1450
+ matchedTarget: matchedTargetValue
1451
+ });
1452
+ if (!isCritical) {
1453
+ const exception = findMatchingException(this.role.exceptions, event, activeTask);
1454
+ if (exception) {
1455
+ this.onAuditEntry?.({
1456
+ type: "exception_applied",
1457
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1458
+ agentId: event.agentId,
1459
+ target: matchedTargetValue,
1460
+ action: event.action,
1461
+ exceptionTarget: exception.target,
1462
+ taskId: activeTask?.taskId ?? null
1463
+ });
1464
+ if (exception.requiresApproval) {
1465
+ const ctx = {
1466
+ finding,
1467
+ exception,
1468
+ activeTask,
1469
+ expiresAt: exception.expiresAfter != null && activeTask ? new Date(new Date(activeTask.startedAt).getTime() + exception.expiresAfter) : null
1470
+ };
1471
+ const approved = this.approvalFn?.(ctx) === true;
1472
+ if (approved) {
1473
+ this.onAuditEntry?.({
1474
+ type: "exception_approved",
1475
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1476
+ agentId: event.agentId,
1477
+ target: matchedTargetValue,
1478
+ action: event.action,
1479
+ exceptionTarget: exception.target,
1480
+ taskId: activeTask?.taskId ?? null
1481
+ });
1482
+ return null;
1483
+ }
1484
+ } else {
1485
+ return null;
1486
+ }
1487
+ }
1488
+ }
1489
+ return this.enhanceWithSensitivity(finding, event);
1490
+ }
1343
1491
  makeFinding(event, details) {
1344
1492
  return {
1345
1493
  severity: details.severity,
@@ -1759,18 +1907,15 @@ var TargetSensitivityScorer = class {
1759
1907
  };
1760
1908
 
1761
1909
  export {
1762
- DEFAULT_MAP_PATH,
1763
- saveMap,
1764
- loadMap,
1765
1910
  DEFAULT_OVERLAY_PATH,
1766
1911
  saveOverlay,
1767
1912
  loadOverlay,
1768
1913
  applyOverlay,
1769
1914
  removeOverlayDecision,
1770
1915
  createEmptyOverlay,
1771
- DEFAULT_FORBIDDEN_PATTERNS,
1772
- withPolicyReadExceptions,
1773
- DEFAULT_MEDIUM_DISPOSITION,
1916
+ DEFAULT_MAP_PATH,
1917
+ saveMap,
1918
+ loadMap,
1774
1919
  TargetSensitivityScorer,
1775
1920
  tokenizePaths,
1776
1921
  FORBIDDEN_BASENAMES,
@@ -1788,4 +1933,4 @@ export {
1788
1933
  findMatchingException,
1789
1934
  RoleValidator
1790
1935
  };
1791
- //# sourceMappingURL=chunk-QIYQWOLO.js.map
1936
+ //# sourceMappingURL=chunk-FIEIGBYL.js.map