Skip to content

Commit 41f0632

Browse files
nawi-25claude
andcommitted
test: add Layer 1 security invariant tests + restore precedence docs
- Add tests proving built-in block rules (block-rm-rf-home, block-force-push) cannot be bypassed by a user-defined allow rule - Restore Configuration Precedence section to README with 5-tier waterfall and note that built-in blocks always fire before user rules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4950297 commit 41f0632

File tree

2 files changed

+62
-0
lines changed

2 files changed

+62
-0
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,22 @@ node9 shield status # see what's currently active
136136

137137
---
138138

139+
## 🔗 Configuration Precedence
140+
141+
Node9 merges configuration from multiple sources in priority order. Higher tiers win:
142+
143+
| Tier | Source | Notes |
144+
| :--- | :------------------------ | :-------------------------------------------------------- |
145+
| 1 | **Environment variables** | `NODE9_MODE=strict` overrides everything |
146+
| 2 | **Cloud / Org policy** | Set in the Node9 dashboard — cannot be overridden locally |
147+
| 3 | **Project config** | `node9.config.json` in the working directory |
148+
| 4 | **Global config** | `~/.node9/config.json` |
149+
| 5 | **Built-in defaults** | Always active, no config needed |
150+
151+
Smart rules from all layers are **concatenated** in evaluation order (first-match-wins): built-in defaults → global → project → shields → advisory defaults. This means built-in `block` rules always fire before any user-defined `allow` rules — a user config cannot bypass Layer 1 protection.
152+
153+
---
154+
139155
## ⚙️ Custom Rules (Advanced)
140156

141157
Most users never need this. If you need protection beyond Layer 1 and the available shields, add **Smart Rules** to `node9.config.json` in your project root or `~/.node9/config.json` globally.

src/__tests__/core.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,52 @@ describe('authorizeHeadless — smart rule hard block', () => {
827827
});
828828
});
829829

830+
// ── Layer 1 security invariant ────────────────────────────────────────────────
831+
// Built-in block rules (Layer 1) are evaluated BEFORE user-defined rules.
832+
// A user allow rule must never be able to bypass a built-in block.
833+
834+
describe('Layer 1 security invariant — built-in blocks cannot be bypassed', () => {
835+
it('block-rm-rf-home fires before a user allow rule on the same command', async () => {
836+
// User adds an allow rule that would match rm -rf ~ if evaluated first.
837+
mockProjectConfig({
838+
policy: {
839+
smartRules: [
840+
{
841+
name: 'user-allow-rm',
842+
tool: 'bash',
843+
conditions: [{ field: 'command', op: 'matches', value: 'rm' }],
844+
verdict: 'allow',
845+
reason: 'user allow — should NOT fire before block-rm-rf-home',
846+
},
847+
],
848+
},
849+
});
850+
const result = await evaluatePolicy('bash', { command: 'rm -rf ~' });
851+
// block-rm-rf-home (Layer 1) must win — not the user allow rule
852+
expect(result.decision).toBe('block');
853+
expect(result.blockedByLabel).toMatch(/block-rm-rf-home/);
854+
});
855+
856+
it('block-force-push fires before a user allow rule on the same command', async () => {
857+
mockProjectConfig({
858+
policy: {
859+
smartRules: [
860+
{
861+
name: 'user-allow-git',
862+
tool: 'bash',
863+
conditions: [{ field: 'command', op: 'matches', value: 'git' }],
864+
verdict: 'allow',
865+
reason: 'user allow — should NOT fire before block-force-push',
866+
},
867+
],
868+
},
869+
});
870+
const result = await evaluatePolicy('bash', { command: 'git push --force origin main' });
871+
expect(result.decision).toBe('block');
872+
expect(result.blockedByLabel).toMatch(/block-force-push/);
873+
});
874+
});
875+
830876
// ── shouldSnapshot ────────────────────────────────────────────────────────────
831877
describe('shouldSnapshot', () => {
832878
const baseConfig = () => JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as typeof DEFAULT_CONFIG;

0 commit comments

Comments
 (0)