DEV Community

Cover image for I Locked Node Into One Directory — Five Doors Stayed Open
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Locked Node Into One Directory — Five Doors Stayed Open

A single Node process serves this blog. Astro's SSR output, server/entry.mjs,
comes up inside a container and reads posts out of a SQLite file. So there is a
long-lived process on that machine that can read files and accepts HTTP requests
from outside. One evening, while reading the deploy script, a question occurred
to me: if that process ever tried to read a file it has no business reading,
what would stop it?

The answer was "nothing". Whatever the container's root filesystem offers, Node
can read. Since Node 22.13 there has been an official tool for exactly this: the
permission model. You turn it on with --permission, you say which paths may
be read with --allow-fs-read, and everything else comes back as
ERR_ACCESS_DENIED. The docs mark it Stability 2 — Stable.

So I sat down and tried it. Not to see whether it works — I expected it to work.
My question was different: how many ways are there around the wall? Eight
routes, across four Node versions. On the v22.16.0 on my own machine six of them
worked; on today's stable release, v26.13.0, five still do.

Building the wall

The lab is two directories: one allowed, one not. I also dropped a symbolic link
into the allowed directory — a shortcut pointing at the forbidden file.

$ LAB=$HOME/izin-lab
$ mkdir -p $LAB/izinli $LAB/yasakli
$ echo ISARET_IZINLI_42 > $LAB/izinli/gizli.txt
$ echo 'export const x = 1;' > $LAB/izinli/kod.js
$ echo ISARET_YASAK_99  > $LAB/yasakli/kasa.txt
$ echo 'LAB_ISARET=env_dosyasindan_geldi' > $LAB/yasakli/.env
$ ln -s $LAB/yasakli/kasa.txt $LAB/izinli/kisayol.txt
Enter fullscreen mode Exit fullscreen mode

First I wanted to see the wall actually hold. One grant: the izinli directory.

$ node --permission --allow-fs-read=$LAB/izinli -e '...'
~/izin-lab/izinli/gizli.txt -> ISARET_IZINLI_42
~/izin-lab/yasakli/kasa.txt -> ERR_ACCESS_DENIED
Enter fullscreen mode Exit fullscreen mode

The wall holds. The error object is generous, too — which permission scope,
which resource:

  code: 'ERR_ACCESS_DENIED',
  permission: 'FileSystemRead',
  resource: '/Users/mustafaerbay/izin-lab/yasakli/kasa.txt'
Enter fullscreen mode Exit fullscreen mode

Read and write are separate scopes; a read grant buys you no write access:

$ node --permission --allow-fs-read=$LAB/izinli -e "...writeFileSync(izinli/yeni.txt)"
write: ERR_ACCESS_DENIED | FileSystemWrite
Enter fullscreen mode Exit fullscreen mode

So far so good. Now let us count the doors.

Door 1: a symbolic link points outside

Inside the allowed directory sits a shortcut to the forbidden file. Will Node
follow it?

$ node --permission --allow-fs-read=$LAB/izinli \
    -e "console.log(require('fs').readFileSync(process.env.LAB+'/izinli/kisayol.txt','utf8'))"
ISARET_YASAK_99
Enter fullscreen mode Exit fullscreen mode

It will. This is not a hole, it is documented behaviour — the "Limitations and
Known Issues" section of Node's permission model documentation says plainly that
symbolic links are followed even to locations outside the set of granted paths.
The docs add a warning: make sure none of the paths you grant contain relative
symbolic links.

In practice that means the directory you pass to --allow-fs-read is now your
responsibility. Any process that can write into it — and node_modules is the
most crowded example of such a directory — can drop a shortcut there and have
the outside read for you. An unpleasant sentence for people like me, who typed
--allow-fs-read=<repo root> and relaxed.

Door 2: everything after the wildcard is ignored

The documentation supports wildcards. Out of plain shell habit, I wanted to say
"read only the JavaScript files":

$ node --permission --allow-fs-read="$LAB/izinli/*.js" \
    -e "console.log(require('fs').readFileSync(process.env.LAB+'/izinli/gizli.txt','utf8'))"
ISARET_IZINLI_42
Enter fullscreen mode Exit fullscreen mode

It read the .txt file. Because * is not a shell pattern here: in the docs'
own words, after a wildcard character all subsequent characters are ignored.
So /home/*.js behaves like /home/*. The grant I wrote as izinli/*.js was
applied as izinli/*.

This is the quietest way to over-grant by typo. No error, exit code zero, no log
line. Just a door wider than you intended. Filtering by extension is not the
permission model's job — do it in your application layer if you like, but you
cannot do it on the grant line.

A second detail: when you grant an existing directory, Node appends a *
automatically; for a directory that does not exist, it does not. The measurement:

$ node --permission --allow-fs-read=$LAB/yokdizin -e '...has("fs.read", LAB+"/yokdizin/x")'
yokdizin/x: false
$ node --permission --allow-fs-read=$LAB/yokdizin/'*' -e '...'
with wildcard, yokdizin/x: true
Enter fullscreen mode Exit fullscreen mode

If you are granting an output directory that has not been created yet, write the
wildcard yourself.

Door 3: --env-file reads before the permission model exists

This blog's server has an /etc/mustafaerbay.env with API keys in it. The other
day, when I handed the same .env file to five
parsers
,
the measurement showed only 4 of its 24 lines producing the same value
everywhere. Now I asked the same file a different question: does the permission model police a file
read through --env-file?

$ node --permission --env-file=$LAB/yasakli/.env \
    -e "console.log(process.env.LAB_ISARET)"
env_dosyasindan_geldi
Enter fullscreen mode Exit fullscreen mode

I granted no --allow-fs-read at all. The file was read anyway. The docs cover
this too: flags such as --env-file and --openssl-config are designed to read
files before environment initialisation, and are therefore not subject to the
permission model's rules. The same goes for V8 flags that can be set at runtime
via v8.setFlagsFromString.

It makes sense — a file is being read while the permission model does not exist
yet. But the consequence stands: having turned the permission model on does not
stop a file named on the command line from being read. Whoever composes the
command line owns that much of the filesystem.

Door 4: node:sqlite walks past the check

This was the one that mattered most to me, because this blog's posts are read out
of a SQLite file. First I put a small database into the forbidden directory —
this step with the permission model off, in an ordinary process:

$ node -e "const {DatabaseSync} = require('node:sqlite');
  const db = new DatabaseSync('$LAB/yasakli/deneme.db');
  db.exec('CREATE TABLE t(x TEXT)');
  db.exec(\"INSERT INTO t VALUES('ISARET_SQLITE_77')\");
  db.close();"
Enter fullscreen mode Exit fullscreen mode

Then I opened that same file from a process whose only grant is the izinli
directory:

$ node --permission --allow-fs-read=$LAB/izinli -e "
  const {DatabaseSync} = require('node:sqlite');
  const db = new DatabaseSync('$LAB/yasakli/deneme.db', {readOnly:true});
  console.log('node:sqlite READ:', db.prepare('SELECT x FROM t').get().x);"
node:sqlite READ: ISARET_SQLITE_77
Enter fullscreen mode Exit fullscreen mode

No grant, no obstacle. This is documented as well: the permission model "does not
guarantee that users will not be able to access the file system through other
means, such as through the node:sqlite module." The reason is simple — the
check lives in the node:fs bindings, while SQLite opens the file in its own C++
layer. Where there is no checkpoint, there is no check.

I got the same result on Node 24.22.2 and 26.13.0. So this is not an old
version's gap; it is the model's boundary.

Diagram

The ordering in that diagram is a claim too: the check sits only on the node:fs
branch. The other three branches either run before the model is set up or never
pass through it.

Door 8: a worker thread given execArgv: []

(I am keeping the table's numbering; 5, 6 and 7 are the next section's subject.)

Node 22's documentation has a single sentence: "The model does not inherit to a
worker thread." I read it and concluded workers run free. Measuring showed the
opposite. Note that both runs below do pass --allow-worker — without it,
constructing the Worker would already be refused in the WorkerThreads scope
and the thing being measured would not be a file read at all:

$ node --permission --allow-fs-read=$LAB/izinli --allow-worker \
    -e "new (require('worker_threads').Worker)('$LAB/izinli/oku.cjs')"
(node:75778) SecurityWarning: The flag --allow-worker must be used with extreme caution. ...
(node:75778) SecurityWarning: The flag --allow-worker must be used with extreme caution. ...
CLOSED ERR_ACCESS_DENIED

$ node --permission --allow-fs-read=$LAB/izinli --allow-worker \
    -e "new (require('worker_threads').Worker)('$LAB/izinli/oku.cjs',{execArgv:[]})"
(node:75780) SecurityWarning: The flag --allow-worker must be used with extreme caution. ...
OPEN ISARET_YASAK_99
Enter fullscreen mode Exit fullscreen mode

The default Worker was blocked. The one that was not blocked is the one I
opened with execArgv: []. The reason: a default worker inherits the parent's
CLI flags, --permission and the --allow-* set included. When you pass
execArgv explicitly — the empty array counts — that list replaces the inherited
flags, and the grants disappear with them.

The proof is sitting in the output already: the first run printed the
SecurityWarning twice, the second once. In the first case the worker that
inherited the flags re-emits the warning on its own account; the worker given
execArgv: [] never received those flags, so it stays quiet. You do not even
need to call process.permission.has() to tell whether inheritance happened.

Current Node documentation now spells this distinction out and adds that the
difference is intended, not a bypass. That single sentence in the Node 22 docs,
on its own, points the reader the wrong way; without measuring I would have
stayed on the wrong side of it. When a doc's short sentence and the code's
behaviour disagree, the code wins.

The door that closed: spawn versus fork

This was the most interesting result. On my own machine, Node v22.16.0, the
parent process could not read the forbidden file but its child could:

== v22.16.0 ==
5 spawn                 OPEN
6 fork                  CLOSED ERR_ACCESS_DENIED
Enter fullscreen mode Exit fullscreen mode

Same parent, same file, two different APIs, opposite outcomes. fork() copies
the parent's execArgv to the child — so --permission travels with it and the
child is born inside the jail. spawn() was not copying it: the child started as
if the permission model had never been switched on. Node prints an honest warning
while this happens:

SecurityWarning: The flag --allow-child-process must be used with extreme
caution. It could invalidate the permission model.
Enter fullscreen mode Exit fullscreen mode

I ran the same script on three more versions — two in containers (Linux), the
last one on my own machine again, with the current LTS downloaded from
nodejs.org:

Route v22.16.0 v22.23.4 v24.22.2 v26.13.0
0 direct read closed closed closed closed
1 symbolic link open open open open
2 wildcard truncation open open open open
3 --env-file open open open open
4 node:sqlite open open open open
5 spawn open closed closed closed
6 fork closed closed closed closed
7 worker closed closed closed closed
8 worker execArgv:[] open open open open

One thing to know while reading that table: 22.16 and 26.13 ran on macOS, 22.23
and 24.22 in Linux containers. In an article about symbolic links and path
grants that is not a trivial detail — but the only difference between the
columns is the spawn row, and the cause of that difference is not the
operating system, it is the patch below. v26.13.0 is the newest stable release
nodejs.org listed on the day I wrote this.

The closing point is on record: the patch titled "permission: propagate
permission model flags on spawn" landed in Node 22.18.0. Its own description
states exactly what the lab showed — previously only child_process.fork
propagated the exec arguments to the child process. Now spawn and
spawnSync do too, not on the command line but through the NODE_OPTIONS
environment variable. The
22.16 on my machine was two patch releases short of it.

The lesson here is about reading versions. "We're on Node 22" maps to two
different rows of that table. Security behaviour changed inside a single major
line.

One more note: none of these experiments ran on Node 20 at all.

== v20.20.2 ==
0 direct read          node: bad option: --permission
Enter fullscreen mode Exit fullscreen mode

On Node 20 the flag is called --experimental-permission. The new name became
stable in v22.13; the old name is still accepted as an alias across the 22 line.
But the trap is symmetric, and that went through the lab too:

$ docker run --rm node:22-alpine node --experimental-permission ...
accepted
$ docker run --rm node:24-alpine node --experimental-permission ...
node: bad option: --experimental-permission
Enter fullscreen mode Exit fullscreen mode

The old name was removed in Node 24. So it is a minefield in both directions:
write --permission and the process never starts on a Node 20 image; write
--experimental-permission and it never starts on a Node 24 one. Both die with
"bad option", and both die before any of your application's code runs — which at
least makes it a loud failure rather than a quiet one.

The comma trap: I wrote two directories, I got zero

I did not go looking for this one; I fell into it. Wanting to allow two
directories, I reached for a comma out of habit from other command-line tools:

$ node --permission --allow-fs-read="$LAB/izinli,$LAB/yasakli" \
    -e "console.log('izinli:',process.permission.has('fs.read','$LAB/izinli/gizli.txt'))"
izinli: false
(node:75172) Warning: The --allow-fs-read CLI flag has changed. Passing a
comma-separated list of paths is no longer valid. Documentation can be found
at https://nodejs.org/api/permissions.html#file-system-permissions
exit code: 0
Enter fullscreen mode Exit fullscreen mode

Neither. The comma-separated list counts as a single path name, and since nothing
exists under that name, no grant is created at all.

Here I have to own a measurement mistake of my own. The first time I ran this
line I was filtering the output through grep -vi warning — a filter I had added
to drop ExperimentalWarning noise from the container runs. That filter
swallowed the warning above, and for a while I walked around believing Node says
nothing at all. Node does say something, and says it well: that the flag's
behaviour changed, with a link to the documentation. The comma form was
invalidated back in v20.7.0.

The trap is not silence, it is severity: this is a Warning, not an
Error. The process starts, the exit code is zero, the application runs — you
simply have no permissions anywhere. For someone filtering warnings out of CI
output, which is exactly what I was doing, it is easy to miss. The correct form
is to repeat the flag:

$ node --permission --allow-fs-read="$LAB/izinli" --allow-fs-read="$LAB/yasakli" -e '...'
izinli: true | yasakli: true
Enter fullscreen mode Exit fullscreen mode

The difference between those two lines could be a systemd unit you believe you
have hardened granting nothing at all, or your application dying on its first
read. After writing your grants, verify them once with
process.permission.has(); it is a one-liner:

$ node --permission --allow-fs-read=/opt/app \
    -e "console.log(process.permission.has('fs.read','/opt/app/dist/x'))"
true
Enter fullscreen mode Exit fullscreen mode

I put my own validator in jail

The lab is all very well, but the real question was what happens in production. This
repository has a 126-line script, scripts/validate-content-sources.mjs, that
checks every article's source list. I tried running it under the permission model
and raised the grants one at a time.

$ node --permission scripts/validate-content-sources.mjs --staged
  permission: 'FileSystemRead',
  resource: '.../scripts/validate-content-sources.mjs'

$ node --permission --allow-fs-read="$PWD" scripts/validate-content-sources.mjs --staged
  permission: 'ChildProcess',
  resource: ''

$ node --permission --allow-fs-read="$PWD" --allow-child-process scripts/validate-content-sources.mjs --staged
Kaynak dogrulamasi: staged icerik yok.      # "source check: nothing staged"
Enter fullscreen mode Exit fullscreen mode

On the first step it could not read the script itself. On the second it stopped
in the ChildProcess scope, because the script calls git diff --cached through
execFileSync.

That first step was leading me to a false generalisation: "under the permission
model you have to grant even your application's own entry file." Before writing
it down I ran the same thing on the other versions, and the generalisation
collapsed:

$ node --permission ~/izin-lab/giris.cjs            # v22.16.0
Error: Access to this API has been restricted...

$ docker run ... node:22-alpine node --permission /s/giris.cjs
entry point ran: v22.23.4

$ docker run ... node:24-alpine node --permission /s/giris.cjs
entry point ran: v24.22.2

$ ./node-v26.13.0/bin/node --permission ~/izin-lab/giris.cjs
entry point ran: v26.13.0
Enter fullscreen mode Exit fullscreen mode

No grants at all, and three of them ran. The reason sits in the docs, in the
version history row for --allow-fs-read: v24.2.0 and v22.17.0 —
"Entrypoints of your application are allowed to be read implicitly."

So the second piece of evidence for this article's backbone — the patch version
is a behaviour version — is inside the same minor line as the first, one patch
earlier. 22.17 opened the entry point, 22.18 brought flag propagation on
spawn. The 22.16 on my machine predates both, which is why the three-step
escalation above reproduces exactly only on that version. On 22.17 and later the
first step does not fail and you start from the second.

That implicit grant ends in a subtle place. I called the same file, with the same
version, through a path whose parent is a symbolic link — on macOS, /tmp is a
link to private/tmp:

$ ./node-v26.13.0/bin/node --permission /tmp/.../giris.cjs
Error: Access to this API has been restricted...
    at Object.realpathSync (node:fs:3479:37)

$ ./node-v26.13.0/bin/node --permission /private/tmp/.../giris.cjs
entry point ran: v26.13.0
Enter fullscreen mode Exit fullscreen mode

Same file, same flag, two spellings. The implicit grant given to the entry point
does not cover resolving that path to its real one; the realpathSync during
resolution wants a grant. Anyone running scripts out of /tmp on macOS will meet
this, and it belongs to the "why doesn't this happen on my machine" family.

The minimal pair turned out to be: read access to the repository root, plus
--allow-child-process. Three runs, same result. But here is the irony: to put a
126-line validator in jail, I have to hand it the grant that — on the Node version
on my machine — removes the jail entirely for its children. On a pre-22.18
Node, the protective value of this setup is limited to the parent's own fs
calls.

The tool chain, not my code, sets the grant list

The scripts that render cover images run on TypeScript through tsx. I tried to
jail that too — without even giving it work to do, just asking for its version.

$ node --permission --allow-fs-read="$R" --allow-fs-read="$NM" node_modules/.bin/tsx --version
  code: 'ERR_ACCESS_DENIED',
  permission: 'FileSystemWrite',
  resource: '/var/folders/nn/fmy2b8m575143sfsvs3wrfz40000gn/T/tsx-501'
Enter fullscreen mode Exit fullscreen mode

tsx writes a cache into the operating system's temporary directory. I granted
the write, and it stopped in a different scope:

$ node --permission ... --allow-fs-read="$T" --allow-fs-write="$T" node_modules/.bin/tsx --version
tsx v4.21.0
  code: 'ERR_ACCESS_DENIED',
  permission: 'ChildProcess',
  resource: ''
Enter fullscreen mode Exit fullscreen mode

It printed the version, then asked for a child process and stopped. The full
working list came to five grants: repo read, node_modules read, temp directory
read, temp directory write, child process. All of that to say tsx --version.

That number has nothing to do with my code. The script I wrote reads two files
and writes a PNG; the length of the grant list is set by the TypeScript runner's
caching strategy and its decision to invoke the compiler in a separate process.
If you are going to attach the permission model to your application, what you are
constraining is not the code you wrote but the entire stack underneath it.

The addon door: courtesy or model?

On the blog's SSR side I hit a different wall. Posts are read with
better-sqlite3, and that is a native addon:

$ node --permission --allow-fs-read="$R" --allow-fs-read="$NM" ... -e "...new Database(...)"
require passed
addon blocked: ERR_DLOPEN_DISABLED
Enter fullscreen mode Exit fullscreen mode

The docs say this as well: under the permission model a process cannot load
native addons by default, and without --allow-addons you get
ERR_DLOPEN_DISABLED. The require('better-sqlite3') line on its own goes
through; the failure happens at the first Database object, the moment the
.node file is actually loaded.

When I did pass --allow-addons, the attempt to open the forbidden database
stopped again — but not because of the permission model:

code: ERR_ACCESS_DENIED | resource: /Users/mustafaerbay/izin-lab/yasakli
    at Object.existsSync (node:fs:292:18)
    at new Database (.../node_modules/better-sqlite3/lib/database.js:64:57)
Enter fullscreen mode Exit fullscreen mode

What stopped it was better-sqlite3's own existsSync call in its JavaScript
layer. The library's courtesy saved me, not the model itself. Doing the same work
with the core node:sqlite module — Door 4 — nobody stopped it at all. The
third-party library got caught because it happened to go through a checked path;
the core module went through because it does not. Read that sentence backwards
and it says a dependency upgrade can quietly change your level of protection.

I did not have to hunt for grants by hand

What I did in the previous two sections — run, take the error, add a grant, run
again — works, but it is primitive. --permission-audit, which arrived in Node
25.8, is precisely the automated version of it: the checks run, violations are
published over node:diagnostics_channel, but access is not denied.

$ cat denetim.cjs
const dc = require('node:diagnostics_channel');
for (const scope of ['fs', 'child', 'worker'])
  dc.subscribe('node:permission-model:' + scope,
    (m) => console.log('violation:', m.permission, '->', m.resource));
require('fs').readFileSync(process.env.SIR, 'utf8');
require('child_process').spawnSync(process.execPath, ['-e', '0']);
console.log('script ran to the end');

$ ./node-v26.13.0/bin/node --permission-audit denetim.cjs
violation: FileSystemRead -> /Users/mustafaerbay/izin-lab/yasakli/kasa.txt
violation: ChildProcess -> .../bin/node
script ran to the end
Enter fullscreen mode Exit fullscreen mode

The whole shopping list in one run: which scope, which resource. In enforcing
mode the same script stops at the first ChildProcess violation and you never
see the rest — which is also why my escalation took three rounds.

A warning: leaving this on in production is not protection, only measurement.
Audit mode blocks nothing. In a real deployment the order should be — first
--permission-audit to produce the list, then write the grants, then enforce
with --permission.

So what is this good for?

Looking at the table so far it is easy to say "pointless", but I think that would
be wrong. Node's own documentation says what it is not in its very first
paragraph: this feature does not protect against malicious code; Node trusts any
code it is asked to run. Malicious code can bypass the permission model and
execute arbitrary code without its restrictions.

So it is not a sandbox, it is a guard rail. When those two get confused,
people lean on the wrong thing. After measuring, I wrote down four questions for
myself:

  • What kind of mistake am I protecting against? It helps against a readFileSync that lost its way, a misconfigured backup script, a library that decides it would like to read /etc too. It does not help against an attacker who can run code inside the process.
  • Which grant am I handing over? --allow-child-process, --allow-addons and --allow-worker lower the wall the moment they are given. Node says so itself: passing each of the four in turn (--allow-wasi included) printed the same SecurityWarning line — "must be used with extreme caution. It could invalidate the permission model."
  • Who can write into the directory I allowed? The symbolic link door closes on exactly this question. Anyone with write access can bore through your read wall.
  • On which Node version? The table above shows why this is a separate question. The patch version is a behaviour version.

If you want an operating-system-level jail, this is not the place. I previously
wrote about Landlock, where an application confines
itself
:
there the check lives in the kernel, and neither node:sqlite nor a .node file
can walk around the rule. The permission model's place is not to replace a kernel
restriction, but to sit as a thin layer above it.

What I settled on

I am not putting the blog's SSR process under the permission model today. The
reason is in the table: posts are read by better-sqlite3, so I would have to
pass --allow-addons; once that grant is given part of the protection goes with
it, and what I get in return is preventing code I wrote myself from accidentally
reading /etc. That trade is already solved more cleanly at the container level
— read-only mounts, a separate user, a narrow filesystem.

But I am considering it for the one-shot scripts under scripts/. They are
short-lived, have few dependencies, mostly use no native addons, and what they
will read is known up front. A guard rail is exactly for places like that. And
the first step is no longer hunting for grants by hand: one run of
--permission-audit, then turn the list into flags.

One last note in fairness: eight routes is not an exhaustive list. The docs' own
"limitations" section names others I did not try — operations on already-open
file descriptors, FFI, OpenSSL loaders, the trace files written by
--trace-event-file-pattern. The lab covered only the ones that could plausibly
show up in my own setup.

I tried eight doors, five are still open, and all five are written down in the
documentation. The difference between reading an instrument's limits and seeing
them measured turned out to be this: I had skimmed past the sentence "symbolic
links will be followed" three times, but I had not noticed what it did to my
--allow-fs-read=<repo root> line until ISARET_YASAK_99 appeared on screen.

Official Sources

Top comments (1)

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

bro 🔥 the wildcard door got mee, izinli/.js silenty becoming izinli/ with exit code 0 and no log line is the worst kind of bug, u think yourr restricted but you are not. i would 100% type that and move on lol

the execArgv: [] one is clever too, using the SecurityWarning printed twice vs once as proof of inheritance, never thought about that trick......

the better-sqlite3 part scared me... so its existsSync in the JS layer saved you and not the model itself, means a dependency update can quitely change your protection?? thats kinda crazyyyyy
with yarn/pnpm workspaces the linked packages in node_modules point outside of it, so by door 1 logic they would just be followed even if u never granted the packages folder right? did you test that or nott? if yes then its a accidental hole AND accidental fix at the same time haha :)

also the grep -vi warning self own made me laugh, i do exact same thing in CI