killm 1.0.0 → 1.0.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
@@ -39,6 +39,36 @@ Editing the hosts file needs elevated privileges:
39
39
  - **macOS / Linux:** prefix with `sudo` → `sudo npx killm for 1h --agents`
40
40
  - **Windows:** run from an **Administrator** terminal
41
41
 
42
+ ### WSL users — read this
43
+
44
+ WSL and Windows have **separate hosts files**. Running `sudo npx killm` inside
45
+ WSL edits WSL's `/etc/hosts`, which only blocks processes running _inside_ WSL
46
+ (curl, node, coding agents in your WSL shell). Your browser is a Windows app —
47
+ it reads `C:\Windows\System32\drivers\etc\hosts` and is **not affected**.
48
+
49
+ To block Windows apps (the browser, Windows-side editors), run killm **on
50
+ Windows** from an Administrator PowerShell:
51
+
52
+ ```powershell
53
+ npx killm for 1h --web
54
+ ```
55
+
56
+ killm detects WSL and prints a warning when this applies. If you want both
57
+ sides blocked, run it in both places.
58
+
59
+ ### Browser caveats
60
+
61
+ Two browser behaviors can make a block look like it isn't working:
62
+
63
+ - **Secure DNS (DNS-over-HTTPS):** when enabled, the browser resolves names
64
+ through an encrypted remote resolver and **bypasses the hosts file
65
+ entirely**. Disable it (Chrome/Edge: Settings → Privacy → Security → "Use
66
+ secure DNS"; Firefox: Settings → Privacy → DNS over HTTPS) for the block to
67
+ apply.
68
+ - **Browser DNS caching:** already-open tabs and cached lookups keep working
69
+ for a while. Restart the browser, or clear its DNS cache
70
+ (`chrome://net-internals/#dns` in Chrome/Edge).
71
+
42
72
  ## The key idea: agents vs. web
43
73
 
44
74
  The whole point of `killm` is that an agentic coding tool and a chat website
@@ -95,6 +125,7 @@ You can combine `--agents` and `--web`; that's the same as `--all`.
95
125
 
96
126
  | Option | Effect |
97
127
  | ----------------- | ------------------------------------------------ |
128
+ | `--firewall` | Also block current IPs at the OS firewall |
98
129
  | `--restore` | Lift any active block right now and exit |
99
130
  | `--status` | Report whether a block is currently active |
100
131
  | `--list` | Print the hostnames a given scope would block |
@@ -139,6 +170,35 @@ lines — your existing hosts entries are left alone.
139
170
  If something goes wrong and a block is left behind, `sudo npx killm --restore`
140
171
  (or the Administrator equivalent) cleans it up.
141
172
 
173
+ ## Firewall mode (`--firewall`)
174
+
175
+ The hosts file only intercepts name resolution, so a browser with **Secure DNS
176
+ (DoH)** enabled bypasses it. `--firewall` closes that hole: in addition to the
177
+ hosts entries, killm resolves each target hostname to its **current IPs** and
178
+ blocks those at the OS firewall:
179
+
180
+ ```bash
181
+ sudo npx killm for 1h --web --firewall
182
+ ```
183
+
184
+ - **Linux:** `iptables` / `ip6tables` OUTPUT rules, tagged with a `killm`
185
+ comment
186
+ - **Windows:** one outbound Windows Firewall rule named `killm` (via `netsh`)
187
+ - **macOS:** a `pf` anchor named `killm`
188
+
189
+ Rules are removed together with the hosts block (timer, `Ctrl+C`, or
190
+ `killm --restore` — which also sweeps up rules left behind by a crash, since
191
+ they're all tagged).
192
+
193
+ Caveats, honestly stated:
194
+
195
+ - IPs are captured **at block time**; if a provider rotates addresses
196
+ mid-block, new IPs aren't covered.
197
+ - Big providers sit behind shared CDNs — blocking their current IPs _may_
198
+ affect unrelated sites served from the same edge.
199
+ - Inside WSL, iptables rules (like the hosts file) only affect WSL traffic,
200
+ not Windows apps.
201
+
142
202
  ## Limitations & honesty
143
203
 
144
204
  `killm` is a **speed bump, not a vault.** It's designed to defeat reflex, not a
package/dist/src/cli.js CHANGED
@@ -23,6 +23,9 @@ SCOPE (pick one or more; default is --all)
23
23
  --all Block both of the above. This is the default if no scope is given.
24
24
 
25
25
  OPTIONS
26
+ --firewall Also block the targets' current IPs at the OS firewall
27
+ (iptables/ip6tables, Windows Firewall, or pf). Catches
28
+ browsers that bypass the hosts file via Secure DNS (DoH).
26
29
  --restore Remove any active killm block right now and exit.
27
30
  --status Show whether a block is currently active and exit.
28
31
  --list Print the hostnames that would be blocked and exit.
@@ -50,6 +53,7 @@ export function parseArgs(argv) {
50
53
  scope: { agents: false, web: false, all: false },
51
54
  dryRun: false,
52
55
  yes: false,
56
+ firewall: false,
53
57
  };
54
58
  let explicit;
55
59
  let durationInput;
@@ -74,6 +78,9 @@ export function parseArgs(argv) {
74
78
  case '--dry-run':
75
79
  result.dryRun = true;
76
80
  break;
81
+ case '--firewall':
82
+ result.firewall = true;
83
+ break;
77
84
  case '-y':
78
85
  case '--yes':
79
86
  result.yes = true;
@@ -0,0 +1,175 @@
1
+ import { execFile } from 'node:child_process';
2
+ import dns from 'node:dns/promises';
3
+ /**
4
+ * Firewall-level blocking, layered on top of the hosts-file block.
5
+ *
6
+ * The hosts file only intercepts name resolution, so a browser with Secure
7
+ * DNS (DoH) enabled sails right past it. Firewall mode resolves each target
8
+ * hostname to its current IPs and blocks those at the OS firewall:
9
+ *
10
+ * - Linux: iptables / ip6tables OUTPUT rules tagged with a "killm" comment
11
+ * - Windows: a single "killm" outbound netsh advfirewall rule
12
+ * - macOS: a pf anchor named "killm"
13
+ *
14
+ * All rules are identifiable without local state, so `killm --restore` can
15
+ * always clean up, even after a crash.
16
+ */
17
+ export const RULE_TAG = 'killm';
18
+ /** Maximum IPs per netsh rule; Windows accepts a comma list but not unbounded. */
19
+ const WINDOWS_CHUNK = 100;
20
+ export const defaultRunner = (cmd, args, stdin) => new Promise((resolve) => {
21
+ const child = execFile(cmd, args, (error, stdout) => resolve({ ok: !error, stdout: stdout ?? '' }));
22
+ if (stdin != null && child.stdin) {
23
+ child.stdin.write(stdin);
24
+ child.stdin.end();
25
+ }
26
+ });
27
+ /**
28
+ * Resolve every hostname to its current A/AAAA records. Failures are
29
+ * collected, not thrown — an unresolvable host simply can't be blocked by IP.
30
+ */
31
+ export async function resolveTargetIPs(hosts, resolver = dns) {
32
+ const v4 = new Set();
33
+ const v6 = new Set();
34
+ const unresolved = [];
35
+ await Promise.all(hosts.map(async (host) => {
36
+ const [a, aaaa] = await Promise.all([
37
+ resolver.resolve4(host).catch(() => []),
38
+ resolver.resolve6(host).catch(() => []),
39
+ ]);
40
+ if (a.length === 0 && aaaa.length === 0) {
41
+ unresolved.push(host);
42
+ return;
43
+ }
44
+ for (const ip of a)
45
+ v4.add(ip);
46
+ for (const ip of aaaa)
47
+ v6.add(ip);
48
+ }));
49
+ return { v4: Array.from(v4).sort(), v6: Array.from(v6).sort(), unresolved: unresolved.sort() };
50
+ }
51
+ // ---- pure command builders (unit-tested per platform) --------------------
52
+ /** iptables/ip6tables arguments to add one REJECT rule tagged with killm. */
53
+ export function linuxAddArgs(ip) {
54
+ return ['-I', 'OUTPUT', '-d', ip, '-j', 'REJECT', '-m', 'comment', '--comment', RULE_TAG];
55
+ }
56
+ /**
57
+ * Convert `iptables -S OUTPUT` output into the `-D` argument lists needed to
58
+ * delete every killm-tagged rule. Our rules never contain quoted spaces, so
59
+ * whitespace splitting is safe.
60
+ */
61
+ export function linuxDeleteArgsFromListing(listing) {
62
+ const out = [];
63
+ for (const line of listing.split('\n')) {
64
+ const trimmed = line.trim();
65
+ if (!trimmed.startsWith('-A OUTPUT'))
66
+ continue;
67
+ if (!trimmed.includes(`--comment ${RULE_TAG}`))
68
+ continue;
69
+ out.push(['-D', ...trimmed.slice('-A '.length).split(/\s+/)]);
70
+ }
71
+ return out;
72
+ }
73
+ /** netsh argument lists to add the killm outbound block rule(s). */
74
+ export function windowsAddArgs(ips) {
75
+ const out = [];
76
+ for (let i = 0; i < ips.length; i += WINDOWS_CHUNK) {
77
+ const chunk = ips.slice(i, i + WINDOWS_CHUNK);
78
+ out.push([
79
+ 'advfirewall',
80
+ 'firewall',
81
+ 'add',
82
+ 'rule',
83
+ `name=${RULE_TAG}`,
84
+ 'dir=out',
85
+ 'action=block',
86
+ `remoteip=${chunk.join(',')}`,
87
+ ]);
88
+ }
89
+ return out;
90
+ }
91
+ /** netsh argument list that deletes every rule named killm. */
92
+ export function windowsDeleteArgs() {
93
+ return ['advfirewall', 'firewall', 'delete', 'rule', `name=${RULE_TAG}`];
94
+ }
95
+ /** pf rule text for the killm anchor on macOS. */
96
+ export function darwinAnchorRules(v4, v6) {
97
+ const all = [...v4, ...v6];
98
+ if (all.length === 0)
99
+ return '';
100
+ return `block drop out quick to { ${all.join(', ')} }\n`;
101
+ }
102
+ /**
103
+ * Resolve the hostnames and install firewall rules for their current IPs.
104
+ * Throws on unsupported platforms; individual rule failures are tolerated.
105
+ */
106
+ export async function applyFirewall(hosts, opts = {}) {
107
+ const runner = opts.runner ?? defaultRunner;
108
+ const platform = opts.platform ?? process.platform;
109
+ const { v4, v6, unresolved } = await resolveTargetIPs(hosts, opts.resolver);
110
+ const blocked = v4.length + v6.length;
111
+ // Individual rule failures are tolerated, but if not a single command
112
+ // succeeded the firewall tool itself is missing/unusable — surface that
113
+ // instead of pretending the IPs are blocked.
114
+ let applied = 0;
115
+ const failIfNothingApplied = () => {
116
+ if (blocked > 0 && applied === 0) {
117
+ throw new Error('no firewall rules could be applied (is the firewall tool available?)');
118
+ }
119
+ return { blocked, unresolved };
120
+ };
121
+ if (platform === 'linux') {
122
+ for (const ip of v4)
123
+ if ((await runner('iptables', linuxAddArgs(ip))).ok)
124
+ applied++;
125
+ for (const ip of v6)
126
+ if ((await runner('ip6tables', linuxAddArgs(ip))).ok)
127
+ applied++;
128
+ return failIfNothingApplied();
129
+ }
130
+ if (platform === 'win32') {
131
+ for (const args of windowsAddArgs([...v4, ...v6])) {
132
+ if ((await runner('netsh', args)).ok)
133
+ applied++;
134
+ }
135
+ return failIfNothingApplied();
136
+ }
137
+ if (platform === 'darwin') {
138
+ const rules = darwinAnchorRules(v4, v6);
139
+ if (rules) {
140
+ await runner('pfctl', ['-e']); // best effort: enable pf if it isn't
141
+ if ((await runner('pfctl', ['-a', RULE_TAG, '-f', '-'], rules)).ok)
142
+ applied++;
143
+ }
144
+ return failIfNothingApplied();
145
+ }
146
+ throw new Error(`--firewall is not supported on platform "${platform}"`);
147
+ }
148
+ /**
149
+ * Remove every killm firewall rule. Needs no stored state, so it also cleans
150
+ * up rules left behind by a crashed run. Failures are tolerated — rules that
151
+ * don't exist can't be removed.
152
+ */
153
+ export async function removeFirewall(opts = {}) {
154
+ const runner = opts.runner ?? defaultRunner;
155
+ const platform = opts.platform ?? process.platform;
156
+ if (platform === 'linux') {
157
+ for (const tool of ['iptables', 'ip6tables']) {
158
+ const listing = await runner(tool, ['-S', 'OUTPUT']);
159
+ if (!listing.ok)
160
+ continue;
161
+ for (const args of linuxDeleteArgsFromListing(listing.stdout)) {
162
+ await runner(tool, args);
163
+ }
164
+ }
165
+ return;
166
+ }
167
+ if (platform === 'win32') {
168
+ await runner('netsh', windowsDeleteArgs());
169
+ return;
170
+ }
171
+ if (platform === 'darwin') {
172
+ await runner('pfctl', ['-a', RULE_TAG, '-F', 'rules']);
173
+ return;
174
+ }
175
+ }
package/dist/src/hosts.js CHANGED
@@ -159,3 +159,18 @@ export function hasPrivileges() {
159
159
  return true;
160
160
  return typeof process.getuid === 'function' && process.getuid() === 0;
161
161
  }
162
+ /**
163
+ * Whether we are running inside Windows Subsystem for Linux. A block applied
164
+ * here edits WSL's /etc/hosts only — Windows browsers read the Windows hosts
165
+ * file and are NOT affected.
166
+ */
167
+ export function isWsl(procVersionPath = '/proc/version') {
168
+ if (process.platform !== 'linux')
169
+ return false;
170
+ try {
171
+ return /microsoft/i.test(fs.readFileSync(procVersionPath, 'utf8'));
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ }
package/dist/src/index.js CHANGED
@@ -3,6 +3,7 @@ import { parseArgs, HELP, VERSION } from './cli.js';
3
3
  import { resolveTargets } from './targets.js';
4
4
  import { formatDuration } from './duration.js';
5
5
  import * as hosts from './hosts.js';
6
+ import * as firewall from './firewall.js';
6
7
  // Minimal ANSI styling that degrades to plain text when not a TTY.
7
8
  const tty = process.stdout.isTTY === true;
8
9
  const c = {
@@ -110,6 +111,32 @@ async function runBlock(parsed) {
110
111
  }
111
112
  await hosts.flushDns();
112
113
  out(c.green(` ✓ block active until ${until.toLocaleTimeString()}`));
114
+ let firewallActive = false;
115
+ if (parsed.firewall) {
116
+ try {
117
+ const result = await firewall.applyFirewall(targets);
118
+ firewallActive = true;
119
+ out(c.green(` ✓ firewall: blocked ${result.blocked} IPs at the OS firewall`));
120
+ if (result.unresolved.length > 0) {
121
+ out(c.dim(` (${result.unresolved.length} hostnames did not resolve and were skipped)`));
122
+ }
123
+ }
124
+ catch (e) {
125
+ err(c.yellow(` ⚠ firewall rules could not be applied: ${e.message}`));
126
+ err(c.yellow(' Continuing with the hosts-file block only.'));
127
+ }
128
+ }
129
+ if (hosts.isWsl()) {
130
+ out();
131
+ out(c.yellow(' ⚠ WSL detected: this only blocks processes running inside WSL.'));
132
+ out(c.yellow(' Windows apps (your browser!) read the Windows hosts file and are'));
133
+ out(c.yellow(' NOT affected. To block them, run killm from an Administrator'));
134
+ out(c.yellow(' PowerShell/terminal on Windows: npx killm for 1h --web'));
135
+ }
136
+ out();
137
+ out(c.dim(' note: browsers cache DNS and "Secure DNS" (DoH) bypasses the hosts') +
138
+ '\n' +
139
+ c.dim(' file entirely — restart the browser / disable Secure DNS if needed.'));
113
140
  out();
114
141
  let timer = null;
115
142
  let ticker = null;
@@ -123,6 +150,11 @@ async function runBlock(parsed) {
123
150
  clearTimeout(timer);
124
151
  if (ticker)
125
152
  clearInterval(ticker);
153
+ if (firewallActive) {
154
+ // Fire and forget: rules are tagged, so a missed removal here is still
155
+ // recoverable via "killm --restore".
156
+ void firewall.removeFirewall().catch(() => { });
157
+ }
126
158
  try {
127
159
  const removed = hosts.removeBlock();
128
160
  void hosts.flushDns(); // fire and forget on the way out
@@ -217,6 +249,13 @@ export async function main(argv) {
217
249
  try {
218
250
  const removed = hosts.removeBlock();
219
251
  await hosts.flushDns();
252
+ // Always sweep firewall rules too: they are tagged "killm", so this
253
+ // also cleans up after a crashed --firewall run. Harmless when none
254
+ // exist or the platform is unsupported. Skipped under the test
255
+ // override so tests never touch the real firewall.
256
+ if (!process.env.KILLM_HOSTS_PATH) {
257
+ await firewall.removeFirewall().catch(() => { });
258
+ }
220
259
  out(removed
221
260
  ? c.green('killm: block lifted. Access restored.')
222
261
  : c.dim('killm: no block was active.'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "killm",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Temporarily block your machine from reaching LLM services to curb AI dependency. npx killm for 1h --agents",
5
5
  "bin": {
6
6
  "killm": "dist/src/bin.js"
@@ -15,7 +15,7 @@
15
15
  "build": "tsc",
16
16
  "typecheck": "tsc --noEmit",
17
17
  "pretest": "npm run build",
18
- "test": "node --test dist/test/duration.test.js dist/test/targets.test.js dist/test/cli.test.js dist/test/hosts.test.js dist/test/e2e.test.js",
18
+ "test": "node --test dist/test/duration.test.js dist/test/targets.test.js dist/test/cli.test.js dist/test/hosts.test.js dist/test/firewall.test.js dist/test/e2e.test.js",
19
19
  "lint": "eslint .",
20
20
  "lint:fix": "eslint . --fix",
21
21
  "format": "prettier --write .",