@tuent/sentinel 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,6 +63,10 @@ enforcement:
63
63
  quarantineAfter: 5 # quarantine after this many
64
64
  ```
65
65
 
66
+ `forbid.targets` is a hard deny — a match blocks the action. `allow.targets` is **advisory for file and tool actions**: a read or write outside it is _logged_ as a `scope_violation` but still runs, because Sentinel governs the agent's own tool calls rather than sandboxing the filesystem. The exception is `networkHosts` — a `network_request` to a host not on the list is **denied** by default. That host allowlist governs **tool-level network requests** (WebFetch/WebSearch); it does not contain network access made from _inside_ a Bash command (`curl`, `wget`, etc.) — Bash commands are checked for forbidden file paths, not egress destinations.
67
+
68
+ Tool names Sentinel doesn't recognize — for example, tools added in a Claude Code release newer than your Sentinel build — are **allowed and logged** by default (`enforcement.unknownTools: warn`; every unknown call is recorded as an `unknown_tool` audit finding). Set `enforcement.unknownTools: deny` to block them instead, with `enforcement.allowUnknownTools: [<names>]` as the per-name escape hatch for legitimate new tools.
69
+
66
70
  As policy violations accumulate, the agent escalates through modes: normal → restricted → quarantined (at the `restrictAfter` / `quarantineAfter` thresholds). Release a restricted or quarantined agent through the API (`sentinel.release(...)`), which records the change as a signed entry in the audit trail. Editing the mode state file by hand changes the live state without recording it — leaving the trail and the actual state out of sync.
67
71
 
68
72
  ## Audit trail
@@ -85,7 +89,7 @@ This records the change in the audit trail. Don't edit the mode state file by ha
85
89
 
86
90
  ## Security model
87
91
 
88
- Sentinel's enforcement is **cooperative** — it works by intercepting Claude Code's tool-call hook — and the audit trail is **tamper-evident**, not tamper-proof. For the full threat model — what Sentinel defends against, what's out of scope by design, and current v0.1.0 limitations — see [SECURITY_MODEL.md](./SECURITY_MODEL.md).
92
+ Sentinel's enforcement is **cooperative** — it works by intercepting Claude Code's tool-call hook — and the audit trail is **tamper-evident**, not tamper-proof. Today Sentinel runs on the same host as the agent; running it off-host is the hardened posture on the roadmap. For the full threat model — what Sentinel defends against, what's out of scope by design, and current limitations — see [SECURITY_MODEL.md](./SECURITY_MODEL.md).
89
93
 
90
94
  ## License
91
95
 
package/SECURITY_MODEL.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Sentinel Security Model
2
2
 
3
+ Sentinel's enforcement is **cooperative, not a sandbox**: it governs the agent's own tool calls at the hook layer — it does not intercept syscalls, contain the filesystem, or firewall the network. The audit trail is **tamper-evident, not tamper-proof**. Sentinel currently runs on the same host as the agent; the off-host posture described under "Trust Model" is the hardened, recommended direction. Read every claim below with those three frames in mind.
4
+
3
5
  ## What Sentinel Protects Against
4
6
 
5
7
  ### Pre-Execution Enforcement
6
8
 
7
9
  When using `wrap()` or `wrapTool()`, Sentinel validates every action before the agent executes it. HIGH and CRITICAL severity actions are blocked -- the agent's execution function never runs. The dangerous file is never read, the unauthorized API is never called, the forbidden command is never executed.
8
10
 
9
- This is the strongest integration mode. The agent code passes its intended action and an execute function to Sentinel. If the action violates the role definition or targets a high-sensitivity resource, Sentinel returns `{ blocked: true }` and the execute function is never invoked. LOW and MEDIUM findings are informational -- the action still executes, and the finding is returned alongside the result for logging.
11
+ This is the strongest integration mode. The agent code passes its intended action and an execute function to Sentinel. If the action violates the role definition or targets a high-sensitivity resource, Sentinel returns `{ blocked: true }` and the execute function is never invoked. LOW and MEDIUM findings are informational -- the action still executes, and the finding is returned alongside the result for logging. (One exception in the blocking direction: a MEDIUM out-of-scope `network_request` is denied by default -- see "Unauthorized Target Access" below.)
10
12
 
11
13
  ### Role Violations
12
14
 
@@ -19,6 +21,8 @@ Targets are checked against glob patterns in two layers:
19
21
  1. **Forbidden patterns** (checked first) -- any match produces a **HIGH** `unauthorized_target`
20
22
  2. **Allowed patterns** (checked second) -- access outside allowed scope produces a **MEDIUM** `scope_violation`
21
23
 
24
+ A `scope_violation` is **advisory by default, not a block**: an out-of-scope `file_read`, `file_write`, or `tool_invocation` is **logged and still runs** (`DEFAULT_MEDIUM_DISPOSITION` leaves these actions at `allow`). The exception is `network_request` -- an out-of-scope or unlisted host is **denied** by default. So the allowlist _enforces_ egress **for `network_request` actions** (the WebFetch/WebSearch tool surface) but only _annotates_ out-of-scope file and tool activity: forbidden-pattern hits (HIGH) always block; allowed-scope misses (MEDIUM) do not, except for network. The host allowlist does **not** govern network access made from inside a Bash command — see "Bash-Mediated Network Egress" under the limitations below. Sentinel governs the agent's own tool calls; it is not a filesystem sandbox.
25
+
22
26
  Pattern matching uses `**` (any path) and `*` (single segment). Examples:
23
27
 
24
28
  - `**/.env` matches `.env` at any depth
@@ -64,6 +68,31 @@ All target paths are normalized with `path.normalize()` before pattern matching.
64
68
  - `src/../.env` is normalized to `.env` before checking against `**/.env`
65
69
  - `project/subdir/../../.ssh/id_rsa` is normalized to `.ssh/id_rsa`
66
70
 
71
+ ### Self-Protection: Policy and Hook-Wiring Files
72
+
73
+ The agent's own tool-writes to the live policy file (`.sentinel.yaml`) and to Claude Code's hook-wiring settings files (`.claude/settings.json` and `.claude/settings.local.json`, project- and user-level) are **denied by default**. Without this, an agent could rewrite its own policy or remove the hook entries that route its tool calls through the gateway with an ordinary file write. The corresponding forbid patterns ship in the defaults and the starter policy; the read carve-out is appended **code-side at ceiling-role construction** and is deliberately not authorable from the workspace yaml (workspace-authored exceptions are dropped by the ceiling merge, so a policy file can never widen its own protection away).
74
+
75
+ **Defaults are a floor, not a fallback.** The built-in forbidden patterns are unioned into every role at role construction, regardless of what the policy file supplies. A policy file can extend the built-in list but cannot remove entries from it — omitting a built-in pattern from a yaml's `forbid.targets` does not disable it. This is deliberate: a stale policy file (written before newer built-in protections existed) or a hostile one cannot weaken built-in protection by superseding the list. Legitimately relaxing a built-in pattern goes through the operator-ceiling policy's `exceptions` mechanism, which is an explicit, auditable act — never through omission.
76
+
77
+ Honest boundaries of this protection:
78
+
79
+ - **Reads are allowed via the Read tool only.** Bash-mediated reads that name these files (`cat`, `grep`, redirection sources) are denied — the gateway's bash layers have no exception plumbing. The agent inspects its policy and hook wiring with the Read tool.
80
+ - **Plain bash writes to the policy/settings files are denied, but encoding-obfuscated bash writes are not decoded for these patterns.** Their basenames are deliberately excluded from the content-scan list to avoid false positives on source files that mention them. This is an accepted limitation.
81
+ - **Operators edit these files directly.** Agent tool-writes are denied by design; a human editing `.sentinel.yaml` or the settings files in an editor or separate shell is outside the agent tool-call surface and unaffected.
82
+ - **`~/.claude.json` is not part of Claude Code's honored-hooks surface** (it holds legacy global state, not hook configuration) and is therefore not on the forbid list.
83
+ - **Managed settings and launch-flag settings files are admin surface**, controlled outside the agent's tool-call path, and out of tool-write scope for this protection.
84
+
85
+ ### Unknown Tool Names
86
+
87
+ Tool names outside Sentinel's recognized native set (and not MCP-shaped — `mcp__*` names have their own arg-scanning translation path) cannot be evaluated by name-aware policy checks. The gateway's disposition for them is the `enforcement.unknownTools` knob in the operator launch policy:
88
+
89
+ - **`warn` (shipped default):** the call is allowed and a persisted, observational `unknown_tool` finding is written to the audit trail — no unknown tool passes unlogged. This is the default because the recognized set is inventoried from live sessions and cannot be proven complete against capability-gated or newer Claude Code tools; deny-by-default on an unverifiable list risks breaking legitimate cc features. The default is intended to flip to deny once warn-mode telemetry reconciles the inventory against live traffic.
90
+ - **`deny` (hardened opt-in):** the call is blocked with a HIGH `unknown_tool` finding. The deny message names the blocked tool and the escape hatch: `enforcement.allowUnknownTools` (a list of names to translate as known) plus a daemon restart — so a newly shipped cc tool can be unbricked with a one-line policy edit, without waiting on a Sentinel release.
91
+
92
+ `unknown_tool` findings are deliberately **not escalation-eligible**: the dominant real-world cause is a recognized set gone stale after a Claude Code update, not agent misbehavior, and counting them would turn every cc update into a restriction ratchet.
93
+
94
+ **Gateway-down behavior is deliberately stricter:** the fail-closed hook hard-blocks unknown names regardless of the knob, because the hook cannot persist findings while the daemon is down — a warn-equivalent there would be allow-_unlogged_, which is exactly the gap this protection closes. `allowUnknownTools` names are baked into the hook at init so the operator escape hatch survives gateway-down; re-run init after editing the list.
95
+
67
96
  ---
68
97
 
69
98
  ## What Sentinel Does NOT Protect Against
@@ -86,17 +115,29 @@ For real-time pre-execution enforcement, use one of:
86
115
 
87
116
  If an AI agent and Sentinel run on the same host and the agent has write access to the filesystem, a compromised agent could theoretically modify its own log files or audit trail. **Mitigation:** Run Sentinel on a separate host and ingest events via the webhook adapter.
88
117
 
118
+ ### Bash-Mediated Network Egress
119
+
120
+ The `networkHosts` allowlist is enforced for `network_request` actions — the WebFetch/WebSearch tool surface. It does **not** contain network access initiated from inside a Bash command: `curl`, `wget`, a script that opens a socket, or a package manager's network activity all run under `command_exec`, where Sentinel's bash layers check the command for **forbidden file paths and basenames**, not egress destinations. There is no comprehensive network containment; treat the host allowlist as a control on the agent's tool-level fetches, not a firewall.
121
+
89
122
  ### Encrypted or Obfuscated Data Exfiltration
90
123
 
91
124
  Sentinel sees which targets an agent accesses and what actions it performs. It does not inspect the content of data being read or transmitted. An agent reading a sensitive file and encoding its contents into an innocuous-looking API call would be partially detected (the file read is logged) but the exfiltration channel may not be flagged.
92
125
 
126
+ ### Self-Hosting Development Boundary
127
+
128
+ In a repository that contains Sentinel's own source code, the agent can still edit the enforcement code itself (and the running daemon picks the change up on its next restart). The policy/hook-wiring write-deny does not — and cannot — close that vector; it is inherent to developing Sentinel with the agent it monitors. Treat enforcement-source changes in such a repo as requiring the same human review as any security-sensitive code.
129
+
93
130
  ### Multi-Agent Coordinated Attacks
94
131
 
95
132
  Each agent is monitored independently by default. The `CorrelationDetector` provides basic cross-agent pattern detection (e.g., read-then-exfiltrate chains), but sophisticated coordinated attacks where each agent stays within its own role may evade detection.
96
133
 
97
- ### Symlink Resolution
134
+ ### Symlink and Hardlink Resolution Limits
98
135
 
99
- RoleValidator operates on path strings, not the filesystem. A symlink named `src/safe-link` pointing to `/etc/shadow` would pass the `src/**` allowed pattern check. Sentinel cannot resolve symlinks without filesystem access on the target host.
136
+ When Sentinel runs on the same host as the agent (the Claude Code gateway integration), validation does resolve links before matching: targets are passed through `realpath` (so a symlink named `src/safe-link` pointing at `/etc/shadow` is checked as `/etc/shadow` and hits the `/etc/**` forbid), and a forbidden-inode comparison catches **hardlinks** to forbidden files that pattern matching cannot see. The honest limits:
137
+
138
+ - **Off-host modes resolve nothing.** The WebhookReceiver / LogAdapter paths operate on path strings without filesystem access to the agent host — there, link-based bypasses are not detected.
139
+ - **Time-of-check vs time-of-use.** A link can be re-pointed between Sentinel's pre-execution check and the tool actually running. This window is inherent to the cooperative model.
140
+ - **Resolution requires the path to exist** at check time; a dangling link that is created after the check is not re-evaluated.
100
141
 
101
142
  ### Concurrent Audit-Trail Writers (No Inter-Process Lock)
102
143
 
@@ -116,7 +157,7 @@ Target matching is deliberately conservative: a command that only _references_ a
116
157
 
117
158
  ### Cold-Start Window
118
159
 
119
- On a cold start, the first tool call of a fresh session waits up to **~5 seconds** for the gateway daemon to warm up. If the daemon is not ready, the hook applies its **tiered fallback** rather than failing closed uniformly: high-sensitivity tools (e.g. `Bash`, `Write`, `Edit`, `WebFetch`) are **denied**, while lower-sensitivity tools (e.g. `Read`, `Glob`, `Grep`, `WebSearch`) are **allowed** through. MCP and unknown tools default to the high (deny) tier.
160
+ On a cold start, the first tool call of a fresh session waits up to **~5 seconds** for the gateway daemon to warm up. If the daemon is not ready, the hook applies its **tiered fallback** rather than failing closed uniformly: high-sensitivity tools (e.g. `Bash`, `Write`, `Edit`, `WebFetch`) are **denied**, while lower-sensitivity tools (e.g. `Read`, `Glob`, `Grep`, `WebSearch`) are **allowed** through. MCP tools default to the high (deny) tier; **unrecognized tool names are always high-tier** — floored, not configurable down (allowing them unlogged while the gateway is down would reopen the unknown-tool gap), with the operator's `allowUnknownTools` names (baked into the hook at init) as the only pass-through.
120
161
 
121
162
  ---
122
163
 
@@ -140,6 +181,8 @@ On a cold start, the first tool call of a fresh session waits up to **~5 seconds
140
181
  - Audit trail is on the Sentinel host, inaccessible to the agent
141
182
  - Alerts dispatch to external systems (Slack webhook, PagerDuty, etc.)
142
183
 
184
+ The off-host posture above is the **hardened, recommended direction**. Note that the **Claude Code gateway integration currently runs same-host** — the gateway daemon, policy file, and audit trail live on the machine the agent runs on, with the self-protection forbids (policy/hook-wiring write-denies, state-dir forbid) as the same-host mitigations. Moving the cc gateway's enforcement core off-host is the roadmap re-architecture; until then, treat same-host deployment with the honesty the "Log Tampering" section above implies.
185
+
143
186
  ### Development Deployment
144
187
 
145
188
  ```
@@ -193,39 +236,46 @@ On a cold start, the first tool call of a fresh session waits up to **~5 seconds
193
236
 
194
237
  ## Detection Capabilities
195
238
 
196
- | Threat | Detection Method | Component | Expected Severity |
197
- | --------------------------------------------- | -------------------------------------------- | -------------------------- | --------------------------------------------- |
198
- | SSH key access (`~/.ssh/id_rsa`) | Target sensitivity (1.0) | RoleValidator + Scorer | CRITICAL |
199
- | `.env` file read | Target sensitivity (1.0) | RoleValidator + Scorer | CRITICAL |
200
- | AWS credential access (`~/.aws/credentials`) | Target sensitivity (1.0) | RoleValidator + Scorer | CRITICAL |
201
- | Kubernetes config access (`~/.kube/config`) | Target sensitivity (0.95) | RoleValidator + Scorer | CRITICAL |
202
- | Write to `~/.ssh/authorized_keys` (backdoor) | Target sensitivity (1.0 x 1.3 write) | RoleValidator + Scorer | CRITICAL |
203
- | `/etc/passwd` read | Target sensitivity (0.9) + forbidden pattern | RoleValidator + Scorer | CRITICAL |
204
- | System config write (`/etc/nginx/nginx.conf`) | Target sensitivity (0.7 x 1.3 write = 0.91) | RoleValidator + Scorer | CRITICAL |
205
- | Payment data access (`payments/`) | Target sensitivity (0.85) | DeviationDetector + Scorer | HIGH |
206
- | Database query by file-only agent | Action not in allowedActions | RoleValidator | HIGH |
207
- | Shell command by code-writer agent | Action not in allowedActions | RoleValidator | HIGH |
208
- | 3x baseline event volume | Event count exceeds threshold | DeviationDetector | MEDIUM (escalates to HIGH with other signals) |
209
- | >50% novel target files in session | Target set comparison | DeviationDetector | MEDIUM |
210
- | Activity outside expected hours | Schedule check (UTC) | RoleValidator | MEDIUM |
211
- | Weekend activity when weekday-only | Schedule check (UTC) | RoleValidator | MEDIUM |
212
- | Path traversal (`src/../.env`) | Path normalization + pattern match | RoleValidator | HIGH/CRITICAL (depends on target) |
213
- | Category shift (rare action type) | Baseline action distribution | DeviationDetector | MEDIUM |
214
- | Access outside allowed scope | Allowed target pattern miss | RoleValidator | MEDIUM |
215
- | Multiple converging anomalies (3+) | Finding count in session | DeviationDetector | MEDIUM escalated to HIGH |
216
- | Activity drop (75%+ fewer events) | Event count vs baseline average | DeviationDetector | MEDIUM (HIGH at 90%+ drop) |
217
- | Extended agent silence (2-3x max gap) | Time since last event vs baseline gaps | DeviationDetector | LOW (MEDIUM at 3x+) |
239
+ | Threat | Detection Method | Component | Expected Severity |
240
+ | --------------------------------------------- | -------------------------------------------- | -------------------------- | ----------------------------------------------------------------- |
241
+ | SSH key access (`~/.ssh/id_rsa`) | Target sensitivity (1.0) | RoleValidator + Scorer | CRITICAL |
242
+ | `.env` file read | Target sensitivity (1.0) | RoleValidator + Scorer | CRITICAL |
243
+ | AWS credential access (`~/.aws/credentials`) | Target sensitivity (1.0) | RoleValidator + Scorer | CRITICAL |
244
+ | Kubernetes config access (`~/.kube/config`) | Target sensitivity (0.95) | RoleValidator + Scorer | CRITICAL |
245
+ | Write to `~/.ssh/authorized_keys` (backdoor) | Target sensitivity (1.0 x 1.3 write) | RoleValidator + Scorer | CRITICAL |
246
+ | `/etc/passwd` read | Target sensitivity (0.9) + forbidden pattern | RoleValidator + Scorer | CRITICAL |
247
+ | System config write (`/etc/nginx/nginx.conf`) | Target sensitivity (0.7 x 1.3 write = 0.91) | RoleValidator + Scorer | CRITICAL |
248
+ | Payment data access (`payments/`) | Target sensitivity (0.85) | DeviationDetector + Scorer | HIGH |
249
+ | Database query by file-only agent | Action not in allowedActions | RoleValidator | HIGH |
250
+ | Shell command by code-writer agent | Action not in allowedActions | RoleValidator | HIGH |
251
+ | 3x baseline event volume | Event count exceeds threshold | DeviationDetector | MEDIUM (escalates to HIGH with other signals) |
252
+ | >50% novel target files in session | Target set comparison | DeviationDetector | MEDIUM |
253
+ | Activity outside expected hours | Schedule check (UTC) | RoleValidator | MEDIUM |
254
+ | Weekend activity when weekday-only | Schedule check (UTC) | RoleValidator | MEDIUM |
255
+ | Path traversal (`src/../.env`) | Path normalization + pattern match | RoleValidator | HIGH/CRITICAL (depends on target) |
256
+ | Category shift (rare action type) | Baseline action distribution | DeviationDetector | MEDIUM |
257
+ | Access outside allowed scope | Allowed target pattern miss | RoleValidator | MEDIUM — advisory (logged, not blocked); `network_request` denied |
258
+ | Multiple converging anomalies (3+) | Finding count in session | DeviationDetector | MEDIUM escalated to HIGH |
259
+ | Activity drop (75%+ fewer events) | Event count vs baseline average | DeviationDetector | MEDIUM (HIGH at 90%+ drop) |
260
+ | Extended agent silence (2-3x max gap) | Time since last event vs baseline gaps | DeviationDetector | LOW (MEDIUM at 3x+) |
218
261
 
219
262
  ---
220
263
 
221
264
  ## Finding Types
222
265
 
223
- | Type | Source | Description |
224
- | --------------------- | -------------------------------- | ----------------------------------------------------------------------------------------- |
225
- | `role_violation` | RoleValidator | Agent performed an action not in its `allowedActions` list |
226
- | `unauthorized_target` | RoleValidator, DeviationDetector | Agent accessed a target matching `forbiddenTargetPatterns` or scoring high on sensitivity |
227
- | `scope_violation` | RoleValidator | Agent accessed a target outside its `allowedTargetPatterns` |
228
- | `temporal_anomaly` | RoleValidator, DeviationDetector | Activity outside expected schedule or baseline typical hours/days |
229
- | `volume_spike` | DeviationDetector | Session event count exceeds baseline average by 3x+ |
230
- | `access_pattern` | DeviationDetector | Unusual target distribution, weight anomaly, or category shift |
231
- | `behavioral_absence` | DeviationDetector | Significant activity drop (75%+ below baseline) or extended silence (2-3x max gap) |
266
+ | Type | Source | Description |
267
+ | --------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
268
+ | `role_violation` | RoleValidator | Agent performed an action not in its `allowedActions` list |
269
+ | `unauthorized_target` | RoleValidator, DeviationDetector | Agent accessed a target matching `forbiddenTargetPatterns` or scoring high on sensitivity |
270
+ | `scope_violation` | RoleValidator | Agent accessed a target outside its `allowedTargetPatterns` |
271
+ | `temporal_anomaly` | RoleValidator, DeviationDetector | Activity outside expected schedule or baseline typical hours/days |
272
+ | `volume_spike` | DeviationDetector | Session event count exceeds baseline average by 3x+ |
273
+ | `access_pattern` | DeviationDetector | Unusual target distribution, weight anomaly, or category shift |
274
+ | `behavioral_absence` | DeviationDetector | Significant activity drop (75%+ below baseline) or extended silence (2-3x max gap) |
275
+ | `intent_drift` | Similarity engine (active task) | Action poorly aligned with the declared active task — informational, never blocks |
276
+ | `bash_analysis` | Gateway L3 | Dangerous construct / unparseable command without forbidden indicators — informational |
277
+ | `hook_block` | HookEngine | A registered pre-execution hook vetoed the action |
278
+ | `workspace_mismatch` | Gateway routing | Request with no/unresolvable workspace — fail-closed routing refusal; not escalation-eligible |
279
+ | `agent_restricted` | Enforcement ladder | Action denied because the agent is in restricted mode |
280
+ | `agent_quarantined` | Enforcement ladder | Action denied because the agent is quarantined |
281
+ | `unknown_tool` | Gateway unknown-tool consumer | Tool name outside the recognized set — logged-allow by default (`warn`), deny opt-in; not escalation-eligible |
@@ -0,0 +1,10 @@
1
+ import {
2
+ Sentinel
3
+ } from "./chunk-GRN5P3H2.js";
4
+ import "./chunk-QIYQWOLO.js";
5
+ import "./chunk-WLIDSTS4.js";
6
+ import "./chunk-NUXSUSYY.js";
7
+ export {
8
+ Sentinel
9
+ };
10
+ //# sourceMappingURL=Sentinel-XMSJE4DZ.js.map
@@ -259,8 +259,13 @@ interface SecurityFinding {
259
259
  * routing error, not that agent's misbehavior. Same non-eligible posture as
260
260
  * `bash_analysis`. Escalation-ineligibility is a consequence of what the type
261
261
  * means, not a knob — see Sentinel.ESCALATION_ELIGIBLE_TYPES.
262
+ *
263
+ * `unknown_tool` (Sprint 26 Gate-A Item D / F-8) is likewise non-eligible:
264
+ * its dominant real-world cause is a recognized-tool set gone stale after a
265
+ * Claude Code update, not agent misbehavior — counting it would turn every
266
+ * cc update into a restriction ratchet.
262
267
  */
263
- type: "scope_violation" | "temporal_anomaly" | "access_pattern" | "volume_spike" | "unauthorized_target" | "role_violation" | "behavioral_absence" | "agent_quarantined" | "agent_restricted" | "intent_drift" | "hook_block" | "bash_analysis" | "workspace_mismatch";
268
+ type: "scope_violation" | "temporal_anomaly" | "access_pattern" | "volume_spike" | "unauthorized_target" | "role_violation" | "behavioral_absence" | "agent_quarantined" | "agent_restricted" | "intent_drift" | "hook_block" | "bash_analysis" | "workspace_mismatch" | "unknown_tool";
264
269
  agentId: string;
265
270
  agentName: string;
266
271
  description: string;
@@ -278,6 +283,22 @@ interface SecurityFinding {
278
283
  args: string[];
279
284
  };
280
285
  softSignal?: boolean;
286
+ /**
287
+ * True when the forbidden token was a confident command-string MENTION (a
288
+ * proper substring of an argv token, no L1 path-glob hit, no ambiguity) rather
289
+ * than a forbidden-file access. Excluded from getEffectiveBlockCount. Absent /
290
+ * false = counted (today's behavior). Strict-safe: a real file open resolves to
291
+ * an L1 path hit, so it can never be mentionOnly.
292
+ */
293
+ mentionOnly?: boolean;
294
+ /**
295
+ * Distinct-target dedup key for getEffectiveBlockCount — the COMMAND identity
296
+ * (event.primaryTarget). Distinct real accesses, including same-basename-family
297
+ * files (`cat .env` vs `cat .env.local`), get distinct keys and count
298
+ * separately; a true repeat of the same command dedups to one. Absent = the
299
+ * finding is its own distinct entry (never merged).
300
+ */
301
+ dedupKey?: string;
281
302
  }
282
303
  /** Computed behavioral baseline for an agent over a time window. */
283
304
  /**
@@ -1778,6 +1799,15 @@ declare class Sentinel {
1778
1799
  * Sprint 16 Prompt 3 — replaces in-memory blockCounts Map to survive
1779
1800
  * gateway restarts. Same pattern as Sprint 11 sessionCount Shape D fix.
1780
1801
  */
1802
+ /**
1803
+ * Shared escalation-eligibility predicate (Sprint 26 F-5). Single source of
1804
+ * truth for "this finding counts toward the block ladder", extracted from the
1805
+ * formerly-inline copies in getEffectiveBlockCount and maybeEscalate — behavior
1806
+ * of both is identical to before the extraction. Accepts either a
1807
+ * SecurityFinding-shaped object ({type}) or an audit entry ({findingType},
1808
+ * mapped by the caller).
1809
+ */
1810
+ private static isEscalationEligible;
1781
1811
  private getEffectiveBlockCount;
1782
1812
  private maybeEscalate;
1783
1813
  }
@@ -6,8 +6,12 @@ import {
6
6
  discoverPolicy
7
7
  } from "./chunk-FMZWHT4M.js";
8
8
  import {
9
+ FORBIDDEN_BASENAMES
10
+ } from "./chunk-QIYQWOLO.js";
11
+ import {
12
+ loadPolicy,
9
13
  loadPolicyFromString
10
- } from "./chunk-2FFMYSVC.js";
14
+ } from "./chunk-WLIDSTS4.js";
11
15
 
12
16
  // src/setup/initClaudeCode.ts
13
17
  import { access, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
@@ -17,12 +21,16 @@ import { createServer } from "http";
17
21
  import { fileURLToPath } from "url";
18
22
 
19
23
  // src/gateway/hookScriptSource.ts
24
+ var SCORER_CRITICAL_EXTRAS = ["shadow", "passwd"];
25
+ var HOOK_SENSITIVE_BASENAMES = [
26
+ .../* @__PURE__ */ new Set([...FORBIDDEN_BASENAMES, ...SCORER_CRITICAL_EXTRAS])
27
+ ];
20
28
  var HOOK_SCRIPT_SOURCE = `#!/usr/bin/env node
21
29
  // Sentinel cc hook bridge \u2014 generated by sentinel init claude-code
22
30
  // Do not edit manually; regenerate with: sentinel init claude-code --force
23
31
 
24
32
  import { readFileSync, appendFileSync, existsSync } from "node:fs";
25
- import { join } from "node:path";
33
+ import { join, resolve, sep } from "node:path";
26
34
  import { spawn } from "node:child_process";
27
35
 
28
36
  const GATEWAY_ENTRY_POINT = "__GATEWAY_ENTRY_POINT__";
@@ -74,6 +82,17 @@ function logFallback(entry) {
74
82
  try { appendFileSync(FALLBACK_LOG, line + "\\n"); } catch { /* best effort */ }
75
83
  }
76
84
 
85
+ // Sprint 26 Fix-2 Part 1 \u2014 hardcoded fail-closed FLOOR. tiers.json may ADD
86
+ // high-tier tools but can never DOWNGRADE a floor tool to low (defeats the
87
+ // two-step tier-config rewrite). Checked BEFORE the editable tiers below.
88
+ const FLOOR_HIGH = ["Bash", "Write", "Edit", "WebFetch", "NotebookEdit", "Task", "Skill"];
89
+
90
+ // Item D \u2014 operator allowUnknownTools escape hatch, baked at init from the
91
+ // launch policy so it survives gateway-down (the daemon allows these names
92
+ // unconditionally at the name level, so allowing them here keeps down \u2287 up).
93
+ // The marker default is [] \u2014 an un-substituted script stays strictest.
94
+ const ALLOW_UNKNOWN_TOOLS = /* __ALLOW_UNKNOWN_TOOLS__ */ [];
95
+
77
96
  // Tier config uses flat format: { high, low, mcpDefault, unknownDefault } (plan v3.1 spec'd nested but simplified during 5a)
78
97
  function loadTiers() {
79
98
  try {
@@ -90,10 +109,52 @@ function loadTiers() {
90
109
  }
91
110
 
92
111
  function isHighSensitivity(toolName, tiers) {
112
+ if (FLOOR_HIGH.includes(toolName)) return true; // floor: tiers may add high, never downgrade
93
113
  if (tiers.high && tiers.high.includes(toolName)) return true;
94
114
  if (tiers.low && tiers.low.includes(toolName)) return false;
95
115
  if (toolName.startsWith("mcp__")) return tiers.mcpDefault === "high";
96
- return tiers.unknownDefault === "high";
116
+ // Item D \u2014 operator-allowlisted names pass: gateway-up they are translated
117
+ // as known and allowed at the name level, so the escape hatch must survive
118
+ // gateway-down too.
119
+ if (ALLOW_UNKNOWN_TOOLS.includes(toolName)) return false;
120
+ // Item D floor: all other unknown names are always high-tier gateway-down.
121
+ // Deliberately stricter than gateway-up's warn default: the hook cannot
122
+ // persist findings while the daemon is down, so a warn-equivalent here
123
+ // would be allow-UNLOGGED \u2014 the exact F-8 hole. Hard-deny keeps down \u2287 up
124
+ // in both knob modes (same fail-closed stance as Fix-2's read handling),
125
+ // and a tiers.json edit (unknownDefault: "low") cannot reopen it. Named
126
+ // tools can still be tiered low explicitly via tiers.low above.
127
+ return true;
128
+ }
129
+
130
+ // Sprint 26 Fix-2 Part 2 \u2014 gateway-down safe-read exception.
131
+ // INVARIANT: on gateway-down a read is NEVER more permissive than gateway-up.
132
+ // Low-tier file reads (Read/Glob/Grep) fail CLOSED by default; only a
133
+ // structurally-provably-safe target is allowed. The guard is GENERATED from the
134
+ // canonical FORBIDDEN_BASENAMES (\u222A scorer extras), so it can't drift open.
135
+ const SENSITIVE_BASENAMES = ${JSON.stringify(HOOK_SENSITIVE_BASENAMES)};
136
+ const READ_TOOL_PATH_KEYS = { Read: "file_path", Glob: "pattern", Grep: "path" };
137
+
138
+ function isProvablySafeRead(toolName, toolInput, payloadCwd) {
139
+ const key = READ_TOOL_PATH_KEYS[toolName];
140
+ if (!key) return false; // not a path-bearing read tool \u2014 not provably safe
141
+ const raw = toolInput && toolInput[key];
142
+ if (typeof raw !== "string" || raw.length === 0) return false; // no target \u2192 not provable
143
+ if (raw[0] === "/" || raw[0] === "~") return false; // absolute / home \u2192 fail closed
144
+ const cwd = typeof payloadCwd === "string" && payloadCwd ? payloadCwd : process.cwd();
145
+ const cwdNorm = resolve(cwd);
146
+ const norm = resolve(cwd, raw);
147
+ // Must resolve strictly within cwd (rejects any .. escape).
148
+ if (norm !== cwdNorm && !norm.startsWith(cwdNorm + sep)) return false;
149
+ const segments = norm.slice(cwdNorm.length).split(sep).filter(Boolean);
150
+ for (const seg of segments) {
151
+ const low = seg.toLowerCase();
152
+ if (low[0] === ".") return false; // dotfile / dot-dir
153
+ for (const s of SENSITIVE_BASENAMES) {
154
+ if (low.includes(s.toLowerCase())) return false; // conservative sensitive-token match
155
+ }
156
+ }
157
+ return true;
97
158
  }
98
159
 
99
160
  // ---------------------------------------------------------------------------
@@ -111,15 +172,29 @@ if (mode === "pre") {
111
172
  const result = JSON.parse(resp.body);
112
173
  process.stdout.write(JSON.stringify(result), () => process.exit(0));
113
174
  } catch {
114
- // Gateway unreachable \u2014 tiered fail-closed
175
+ // Gateway unreachable \u2014 tiered fail-closed (Sprint 26 Fix-2).
115
176
  const tiers = loadTiers();
177
+ const restore = "Restart your Claude Code session to relaunch the gateway; see ~/.dahlia/gateway-fallback.log.";
116
178
  if (isHighSensitivity(toolName, tiers)) {
117
179
  logFallback({ event: "fail-closed-block", tool: toolName, tier: "high" });
118
180
  process.stderr.write(
119
- \`Sentinel gateway unreachable; high-sensitivity tool "\${toolName}" blocked per fail-closed policy\`,
181
+ \`Sentinel gateway unreachable; high-sensitivity tool "\${toolName}" blocked per fail-closed policy. \${restore}\`,
182
+ () => process.exit(2),
183
+ );
184
+ } else if (
185
+ READ_TOOL_PATH_KEYS[toolName] &&
186
+ !isProvablySafeRead(toolName, payload.tool_input || {}, payload.cwd)
187
+ ) {
188
+ // Low-tier read whose target is not provably safe \u2192 fail CLOSED. The hook
189
+ // carries no policy, so it cannot evaluate the forbid surface; a read it
190
+ // can't prove safe is treated as if the gateway would deny it.
191
+ logFallback({ event: "fail-closed-block", tool: toolName, tier: "low", reason: "read-not-provably-safe" });
192
+ process.stderr.write(
193
+ \`Sentinel gateway unreachable; read by "\${toolName}" blocked (fail-closed: target not provably safe). \${restore}\`,
120
194
  () => process.exit(2),
121
195
  );
122
196
  } else {
197
+ // Provably-safe read, or a non-read low-tier tool (e.g. WebSearch) \u2192 allow.
123
198
  logFallback({ event: "fail-closed-allow", tool: toolName, tier: "low" });
124
199
  process.stdout.write(JSON.stringify({
125
200
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
@@ -322,6 +397,11 @@ agent:
322
397
  policy:
323
398
  allow:
324
399
  actions: [file_read, file_write, tool_invocation, network_request, command_exec]
400
+ # allow.targets is ADVISORY for file_read / file_write / tool_invocation: an
401
+ # access outside this list is logged as a MEDIUM scope_violation but still
402
+ # runs \u2014 Sentinel governs the agent's own tool calls, it is not a filesystem
403
+ # sandbox. forbid.targets below is the hard deny. network_request is the
404
+ # exception: unlisted hosts are DENIED by default (see the networkHosts note).
325
405
  targets:
326
406
  - "src/**"
327
407
  - "test/**"
@@ -378,6 +458,28 @@ policy:
378
458
  - "**/*.pem"
379
459
  - "**/*.key"
380
460
  - "/etc/**"
461
+ # Sprint 26 FIX 1 (A) \u2014 common credential stores. DRIFT: keep in sync with
462
+ # DEFAULT_FORBIDDEN_PATTERNS in defaults.ts (unification tracked separately).
463
+ - "**/.netrc"
464
+ - "**/.npmrc"
465
+ - "**/.git-credentials"
466
+ - "**/.pgpass"
467
+ - "**/.zsh_history"
468
+ - "**/.config/gh/**"
469
+ - "**/.docker/config.json"
470
+ - "**/.gnupg/**"
471
+ - "**/.config/gcloud/**"
472
+ - "**/.kube/**"
473
+ - "**/Library/Keychains/**"
474
+ # Sprint 26 FIX 1 (B) \u2014 Sentinel's own state dir (current path only).
475
+ - "**/.dahlia/**"
476
+ # Sprint 26 FIX 3 \u2014 this policy file and cc's hook-wiring settings files.
477
+ # Agent tool-writes are denied; reads stay allowed via a code-side ceiling
478
+ # exception (not authorable here \u2014 workspace-authored exceptions are
479
+ # dropped by the ceiling merge, by design). DRIFT: keep in sync with
480
+ # DEFAULT_FORBIDDEN_PATTERNS in defaults.ts.
481
+ - "**/.sentinel.yaml"
482
+ - "**/.claude/settings*.json"
381
483
  enforcement:
382
484
  restrictAfter: 3
383
485
  quarantineAfter: 5
@@ -418,7 +520,16 @@ async function runInitClaudeCode(options) {
418
520
  );
419
521
  const hookPath = join(dahliaDir, "cc-hook.mjs");
420
522
  const gatewayEntryPoint = resolveGatewayEntryPoint();
421
- const hookContent = HOOK_SCRIPT_SOURCE.replace(/__GATEWAY_ENTRY_POINT__/g, gatewayEntryPoint);
523
+ let allowUnknownTools = [];
524
+ try {
525
+ const effectivePolicy = await loadPolicy(policyPath);
526
+ allowUnknownTools = effectivePolicy.enforcement?.allowUnknownTools ?? [];
527
+ } catch {
528
+ }
529
+ const hookContent = HOOK_SCRIPT_SOURCE.replace(
530
+ /__GATEWAY_ENTRY_POINT__/g,
531
+ gatewayEntryPoint
532
+ ).replace("/* __ALLOW_UNKNOWN_TOOLS__ */ []", JSON.stringify(allowUnknownTools));
422
533
  if (hookContent.includes("__GATEWAY_ENTRY_POINT__")) {
423
534
  throw new Error("Failed to substitute all __GATEWAY_ENTRY_POINT__ placeholders");
424
535
  }
@@ -536,4 +647,4 @@ export {
536
647
  runInitClaudeCode,
537
648
  runSessionStart
538
649
  };
539
- //# sourceMappingURL=chunk-WPTJBRX5.js.map
650
+ //# sourceMappingURL=chunk-FWIISAZZ.js.map