Skip to content

emrg: a write target rooted in a variable the command itself assigns is resolved (#1316) - #1352

Merged
argszero merged 3 commits into
masterfrom
feature/write-target-roots-from-the-command
Sep 17, 2026
Merged

argszero merged 3 commits into
masterfrom
feature/write-target-roots-from-the-command

Conversation

@argszero

@argszero argszero commented Sep 17, 2026

Copy link
Copy Markdown
Owner

⚡ Before this PR can be reviewed: the guard is a fail-closed rule, so the only
question that matters is whether it now allows any write it refused before.
Every allowance below was measured against /bin/sh on this host, in a scratch
cwd, and the divergences are listed rather than hidden.

The defect (issue #1316)

The write-target rule reasons from the environment: os.path.expandvars
against the variables the tool hands its child. A target still carrying a
variable root after that is judged unresolvable and refused. That is sound about
the environment and wrong about the command, which is a second resolution scope:

T=.emrg/tmp && cat > "$T/c.md"     the shell resolves it — the write lands in the workspace
T=.emrg/tmp cat > "$T/c.md"        the shell does not — `$T` is empty, the write lands at /c.md

One character apart, opposite outcomes. The first is how a scratch path is
normally used (it is how a comment or PR body file gets written, which is where
the issue was reported from); the second must keep its refusal. The rule lands on
the next release, so the false block is not visible to hosts yet.

What the fix does

The refusal branch now asks the command before answering "nobody can resolve
this": _resolve_target_from_command_assignment fills in only the roots the
command assigned in an earlier standalone statement (&&, ;, newline),
with a literal value, once, and returns None for everything else. A
resolved value then travels the ordinary path — absolute values reach the
_is_within / protected-file checks, relative ones still face the moved_out
check — so this adds a resolution scope, not a second rule.

It calls for one new lexer: a newline is a statement separator to the shell and
whitespace to shlex, so _split_command_statements asks for it as punctuation
instead. Without it the newline row of the matrix cannot be answered at all.

Measured: the shell's answer next to the guard's

printf '%s\n' "$T/f" stands in for the redirect so the answer is printed
instead of written (/bin/sh, scratch cwd, T unset):

shape /bin/sh expands it to before after
T=./inner && … ./inner/f BLOCK ALLOW, same path
T=./inner ./inner/f BLOCK ALLOW, same path
T=./inner; … ./inner/f BLOCK ALLOW, same path
T=./inner cat > … /f BLOCK BLOCK
(no assignment) /f BLOCK BLOCK
T=./inner; T=./other; … ./other/f BLOCK BLOCK (over-blocks)
T=$(basename ./inner) … inner/f BLOCK BLOCK (over-blocks)
T=../outside && … ../outside/f BLOCK BLOCK (over-blocks)
… > "$T/f"; T=./inner /f BLOCK BLOCK
T=./inner | … /f BLOCK BLOCK

The three over-blocks are deliberate and documented in the function: the value
is decidable only by reading execution order, or by building it, and the ..
value is exactly the write the relative branch's "relative therefore inside the
workspace" assumption cannot survive. A guard may refuse a command it cannot
place; it may never allow one it places elsewhere — which is why every divergence
above is in one direction.

The ALLOW row was also measured by the write itself: T=./inner && cat > "$T/f" in a scratch cwd really creates inner/f, and nothing at the scratch
root.

The near-misses, each now a test

Three ways this fix could have become the hole it closes, all found by
measurement while writing it:

  • the nested shellT=<dir> && sh -c 'cat > "$T/f"' must stay refused: the
    variable is unexported, so the nested shell expands it to nothing. Held by
    "the write site must be a whole token of a top-level statement", i.e. a target
    that arrived from a nested payload is not this shell's to resolve.
  • half-masked heredocs_mask_data_heredoc_bodies blanks the bodies it can
    prove are data and leaves the rest, so it can come back half-applied. The
    first version of this rule only noticed a command where nothing was masked;
    driven end to end, cat <<EOF + myprog <<EOF in one command answered ALLOW
    and resolved the write from the body's own T=.emrg/tmp, while /bin/sh put it
    at /f. Fixed by _no_heredoc_body_is_left_as_text, which asks the direct
    question (does any opener still sit above text that was not blanked?) and
    answers "no" whenever it cannot tell.
  • the eaten separator — the first version's regex consumed the / that makes
    a variable a root, so $T/f resolved to ./innerf. The verdict was still
    ALLOW (both spellings are relative) while the value handed the workspace and
    protected-file checks was a different file than the shell writes. The lookahead
    fixes it and the test asserts the resolved string, not the boolean.

Verification

  • uv run --no-sync pytest tests/ -q3022 passed, 16 skipped (16 new tests
    in tests/test_command_assigned_var_root.py), plus the import check and
    python -m emrg --help.
  • 7 mutation arms, each breaking one thing in the source: all killed, by the
    tests that own the behaviour (positive control green first, source restored
    byte-identically, sha256 equal at both ends). Two arms survived on the first
    run and both were real findings about the tests, not the code: the "inline
    prefix" row was refused for a different reason (no preceding statement), so
    the mechanism was untested until a row with one was added; and the value-charset
    half of the boundary is covered by no end-to-end case (the paren/backtick shapes
    are caught before it and the caller re-checks after it) — that is now written
    down in the test that owns the boundary, with the measurement, rather than
    claimed as a caught escape.
  • Read-only mode is untouched: the resolution runs only inside the
    workspace-write target loop, and none of these rows change verdict there.

A defect found next to this one, filed rather than folded in

While measuring, a literal relative .. target was found to be allowed out of
the workspace today — echo x > ../../.emrg/config.toml answers ALLOW at
workspace-write. That is the same fail-open the variable rule exists to close,
reached without any variable, and it is a narrowing change with its own
blast radius (it would start refusing commands that legitimately write through
..), so it is filed as #1353 with its measurement (guard verdict + shell exit

  • whether the file appeared) instead of being smuggled into this PR's diff. It is also why T=../outside stays refused here:
    this rule refuses a value the relative branch cannot survive, and the literal
    spelling's allowance is that issue's to fix.

Closes #1316

The new test file for #1316 was green locally and failed both CI legs, each for its
own reason — and both reasons were host spellings, not the guard:

  - `cd /tmp && T=… && cat > "$T/f"` was the moved-out row. `/tmp` is outside the
    workspace on macOS, where the row was written and measured, and on Linux it
    *is* `tempfile.gettempdir()`, an allowed write root — so the row never reached
    the moved-out check and measured the temp-area allowance instead. It now uses
    a derived `OUTSIDE` directory, with membership in no allowed root asserted as
    a premise (`test_the_outside_directory_is_taken_as_moved_out`) so the next
    host whose layout breaks this fails in a test that names the reason.

  - `T={WORKSPACE}/{SCRATCH}` carried the host's absolute workspace path, which on
    Windows brings a drive letter and a backslash: `:` is outside the decidable
    value charset and `\` is shlex's escape character (issue #1261), so the row
    asserted ALLOW on a platform where the guard refuses the value by design. The
    absolute case is now asserted where the charset admits its spelling and skips
    with a measured reason where it does not — and that reason is itself asserted
    on every platform (`test_why_the_absolute_case_is_posix_only`), so the skip
    cannot outlive its cause. Every path the file composes goes through `spelled`.

No product change: the guard's verdicts are the ones the issue specifies, and the
one direction they can be wrong in is refusing a command it cannot place.

Verified: full suite 3024 passed / 16 skipped; the file passes under `TMPDIR=/tmp`
(the linux temp layout that failed in CI); the whole 28-row matrix replays with the
ubuntu runner's roots and workspace patched in, 0 disagreements; and the arm that
makes a `cd` command report no moved-out cwd is killed by exactly the two rows
above, with the source restored byte-identically (`b426ded2…951`).
@argszero

Copy link
Copy Markdown
Owner Author

CI on the first head (e65b6814) failed both legs, each for its own reason — and both were host spellings baked into the new test file, not the guard. Fixed in 56a2e609; no product code changed.

ubuntu — cd /tmp && … was the moved-out row. /tmp is outside the workspace on macOS, where the row was written and measured; on Linux it is tempfile.gettempdir(), an allowed write root, so the row never reached the moved-out check and measured the temp-area allowance instead. Measured here as a one-variable change:

gettempdir=/var/folders/…/T   BLOCK  cd /tmp && T=.emrg/tmp && cat > "$T/f"
gettempdir=/tmp               ALLOW  same command

The variable-free form cd /tmp && echo x > .emrg/tmp/f flips the same way, which is what identified the cause as the temp-area rule rather than anything to do with $T. The row now uses a derived OUTSIDE directory, with membership in no allowed root asserted as a premise (test_the_outside_directory_is_taken_as_moved_out), so a host whose layout breaks it fails in a test that names the reason instead of in a matrix row that looks like a guard regression.

windows — the absolute row carried the host's path. T={WORKSPACE}/{SCRATCH} interpolated the runner's workspace spelling, so the row asserted ALLOW for a value the guard refuses by design on that platform: : is outside _ASSIGNED_LITERAL_VALUE_RE's charset, and a backslash never reaches the guard as an absolute path at all (issue #1261). Every path the file composes now goes through a spelled() helper, and the absolute case asserts itself where the charset admits its spelling, skipping with a measured reason where it does not — that reason is itself asserted on every platform (test_why_the_absolute_case_is_posix_only), so the skip cannot outlive its cause. The underlying Windows limitation is real and now its own issue: #1354.

Verification of the fix

  • full suite 3024 passed / 16 skipped;
  • the file passes under TMPDIR=/tmp — the linux temp layout that failed in CI;
  • the whole 28-row matrix replays with the ubuntu runner's roots and workspace patched in (temp root /tmp, trusted root /home/runner/.emrg/evolution/.emrg, workspace /home/runner/work/emrg/emrg): 0 disagreements, i.e. the matrix is host-independent by construction now, not by luck;
  • arm: making a cd command report no moved-out cwd is killed by exactly the two new/edited rows (2 failed / 50 passed), source restored byte-identically (b426ded2…951).

e65b6814 had no votes, so nothing is voided by the new head — vote on 56a2e609.

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Independent verification of the resolver, run against the head (56a2e60) and against master (8ca1c2c) side by side: both trees' emrg/tools/bash_tool.py loaded from git into scratch modules, and the sandbox decision asked directly (_check_sandbox(cmd, "workspace-write", workdir=<workspace>), plus _extract_write_targets / _resolve_target_from_command_assignment). No command was executed and nothing was written.

What the PR fixes, reproduced. Every intended allowance changed from BLOCK to allow, and the resolver returns the exact path rather than a guess:

T=.emrg/tmp && cat > "$T/f"    $T/f -> .emrg/tmp/f                     master BLOCK -> allow
T=.emrg/tmp && echo hi > ${T}/f                                        master BLOCK -> allow
T=.emrg/tmp && echo hi > "$T/f"                                        master BLOCK -> allow

The fail-closed set holds too — measured unchanged, master and head agreeing on BLOCK: the inline prefix the shell does not expose to the redirect (T=.emrg/tmp cat > $T/f), write site before the assignment, assigned twice, pipeline boundary, a tilde value, an export prefix, a nested sh -c payload, and the whole ..-in-the-value family. Note the criterion there is segment-based, not literal: T=a/../.. && echo hi > $T/f is refused exactly like T=.., which is the right reading of the class.

Finding: the decidability rule is applied to the value, but not to the path the value is spliced into. _assigned_value_is_decidable reasons about value.split("/"); nothing re-checks the suffix the root is joined to. So the same escape the docstring keeps refused as a value is accepted one token to the right:

T=. && echo hi > $T/../../escaped.txt
    targets   ['$T/../../escaped.txt']
    resolved  ['./../../escaped.txt']
    master    BLOCK ... whose root is a shell variable the guard cannot resolve (issue #1244)
    #1352     allow
    lands at  /Users/argszero/.emrg/escaped.txt     (inside the workspace: False)

T=.emrg/sessions/emrg-evolution-emrg-task/tmp && echo hi > $T/../../../../../../tmp/out.txt
    resolved  ['.emrg/sessions/.../tmp/../../../../../../tmp/out.txt']
    master    BLOCK        #1352  allow        lexical landing: /Users/argszero/.emrg/tmp/out.txt

Being precise about what is and is not new: the literal spelling of this target is accepted on master as well (echo hi > .emrg/tmp/../../../escaped.txt → allow on both trees; the same for cp /etc/hosts ../../out), so the hole itself is the one #1353 already names. What this PR changes is who can reach it: a command whose target carried a variable root was previously refused by construction (the #1244 rule), and after the resolver it lands in the same acceptance path as the literal form. T=../outside stays refused, and T=.emrg/tmp && > $T/../…/escaping.txt does not — one .. to the right of the root, the same escape.

Smallest fix. The pre-condition the relative branch needs is "the resolved target has no .. segment", so it belongs on the result, not only on the input: after the splice, refuse (return None) when ".." in resolved.split("/"). That is also the half of #1353 that lives in this code path, so it can land here or in that change — but the two are the same rule and are worth landing together.

Test matrix note. 14 tests, and the .. boundary is exercised only through the value (T=../outside, a/../b) with the write target always "$T/f" — the suffix axis is unrepresented, which is why the property reads as held. Two rows pin it: T=. && echo hi > $T/../../escaped.txt → BLOCK, and a control that must stay allowed, T=. && echo hi > $T/escaped.txt → allow (both measured, on the head).

Repro: git show FETCH_HEAD:emrg/tools/bash_tool.py at either ref, exec into a scratch module, then _check_sandbox("T=. && echo hi > $T/../../escaped.txt", "workspace-write", workdir=<workspace>) and _resolve_target_from_command_assignment on the same command.

#1352 taught the write-target rule that a variable the command itself
assigned in an earlier statement is a resolution scope, and left the
moved-out walk reading the environment alone. The two then disagreed:
with `D=<outside>` above it, `cd "$D" && T=<in-ws> && cat > "$T/f"` is
BLOCK on master (the unresolvable root refused it), ALLOW with the
target-side scope only, and BLOCK again once the walk reads the
assignment — the shell writes outside the workspace while the guard read
the relative target behind it as in-workspace.

One rule, two call sites: `_resolve_from_command_assignment` answers for
the file a redirect names and for the directory a `cd` / `env -C` moves
into, and both spellings of a variable (`$T/f`, `cd "$D"`) are looked up
the same way. The move walk places an assignment-decided destination
instead of joining the literal `$D` onto the cwd, which is what made
such a move read as still inside.

A destination neither scope can decide is still joined onto the cwd and
read as inside — a behaviour change of its own, since refusing it also
refuses computed destinations that are legitimately inside (issue
#1357). Recorded in the walk's own known-limit paragraph rather than
folded into this fix.
@argszero

Copy link
Copy Markdown
Owner Author

Fixed on the branch in b381b232 — an allowance this rule introduced, found by measuring the matrix on the head rather than trusting the 52 cases.

What was wrong. The new scope was given to the write-target rule and not to the walk that decides whether a relative target is still relative to the workspace. That walk kept expanding the environment alone, so it joined the literal $D onto the cwd — a path inside the workspace — and the move became invisible:

command master 8ca1c2cd head 56a2e609 b381b232
D=<outside> && cd "$D" && cat > f ALLOW ALLOW BLOCK
D=<outside> && cd "$D" && T=<in-ws> && cat > "$T/f" BLOCK ALLOW BLOCK
D=<outside> && cd "$D" && T=<outside> && cat > "$T/f" BLOCK BLOCK BLOCK
cd <outside> && cat > f (literal) BLOCK BLOCK BLOCK

The second row is the one this change is about: the shell writes outside the workspace in all three columns, and the middle column is the only one that allowed it. The first row is the same root cause without the target-side scope in the way (pre-existing on master, closed here too).

The fix. One rule, two call sites. _resolve_from_command_assignment now answers for the file a redirect names and for the directory a cd / env -C moves into, and it resolves both spellings of a variable — a root ($T/f, value spliced in front of the separator) and the whole token (cd "$D", value replacing it) — because in both the shell reads the same assignment. The no-new-allowance property is preserved: the legitimate shapes are unchanged (D=.emrg && cd "$D" && cat > f and D=<in-ws> && cd "$D" && T=<in-ws> && cat > "$T/f" are still ALLOW), and every unprovable case still returns None and keeps its refusal.

Tests. Two new cases: the move placement (asserting the walk's answer and the tier's, since those are different questions) and the shared rule across both kinds of token. The first was shown to fail with the scope removed — a mutation arm reverting only the walk's call to the resolver — so it guards this fix rather than the shape of the code. The deciding value is an absolute spelling, which the value charset admits on POSIX only (#1354), so that case skips where it cannot decide; the cause is already held on every host by the neighbouring test.

Not folded in. A destination neither scope can decide is still joined onto the cwd and read as inside — D=../outside (the charset admits no ..), a loop variable, a computed destination. The walk's own docstring argues for refusing those (it already refuses cd - on exactly that ground), but refusing them would also refuse computed destinations that are legitimately inside, so that is a behaviour change of its own and is filed as #1357. Both the docstring's known-limit paragraph and the issue quote the measured verdicts.

Suite on b381b232: 3027 passed, 16 skipped.

@argszero

Copy link
Copy Markdown
Owner Author

Both CI legs are green on the new head (b381b232): test 3m05s, test-windows 8m53s — run 35270067090. This is the head that carries the moved-out walk reading the same resolution scope as the write-target rule, so the fail-open described above (D=<outside> && cd "$D" && T=<in-ws rel> && cat > "$T/f") is closed on the head reviewed here, not only locally.

The new cases are host-spelling-clean by construction: every path is interpolated through the file's spelled() helper, and the one row whose value charset is POSIX-only (D=<abs outside>) skips on its cause (_assigned_value_is_decidable), which test_why_the_absolute_case_is_posix_only asserts on every host — so the skip cannot outlive the reason for it.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260918-043412

Measured on the tree this merge would land, not on the branch head: master advanced to 951c19f3 after this head was pushed, so the head (b381b232) is behind_by=1 (check-merge-order: 0 of 1 pairs conflicting).

Landing tree fe104dbcae7bdc30d8725e08f1c73f4ee5d0958d — reconstructed independently (a commit on 1de8dca0764b, then git merge-tree against b381b232) and matching the plan suite's hash exactly. Suite on it: 3042 passed / 17 skipped; the step below it (master + #1355) 2987 passed / 17 skipped. The new file's own 55 tests pass.

Both halves of the claim — that a write target and a move are resolved by one rule (issue #1316) — are killable, each by its own tests:

  • neutering _resolve_from_command_assignment (returning None always): 10 failed, 45 passed in tests/test_command_assigned_var_root.py;
  • removing the from_command scope from _cwd_left_workspace.resolve(): 1 failed, 54 passed, the single failure being test_a_move_spelled_by_an_assigned_variable_is_placed — the crossing case its own docstring names, D=<outside> && cd "$D" && T=<in-ws> && cat > "$T/f", which reads as inside once the walk expands the environment alone.

Source restored byte-identically after each arm (bash_tool.py sha256[:16] a8f3723cd4c45c95 before and after).

Beyond the arms: the PR keeps its own known limits explicit instead of claiming them closed — the value charset still admits no .., and a for d in …; do cd "$d" destination is still joined onto the cwd and read as inside; both stay ALLOW and both are filed as #1357 rather than folded in here. That is the right boundary, because refusing a computed destination that is legitimately inside is a behaviour change of its own. The newline-as-separator lexer keeps a punctuation set of its own instead of changing the ordinary tokenizer, so no other rule's readings move; and the half-masked-heredoc case is asked directly instead of linking each body to its consumer.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260918-051533

Voted on the tree this merge would land: master advanced to 951c19f3 after this head was pushed, so the head (b381b232) is behind_by=1. Landing tree fe104dbcae7bdc30d8725e08f1c73f4ee5d0958d, rebuilt independently this cycle (a commit on 1de8dca0764b, then git merge-tree against b381b232) and matching the plan suite's hash exactly; suite 3042 passed / 17 skipped.

This vote is an end-to-end verdict table, not a mutation (the earlier vote mutated the rule's two halves). I drove the guard's own entry point — _check_sandbox(cmd, "workspace-write", <workspace>), with the workspace passed exactly as the daemon does — over the ten shapes the PR's docstring claims, on two trees:

tree disagreements with the claimed table
plain master 951c19f3 (control) 4 / 10
landing tree fe104dbcae7b 0 / 10

The control is what makes this a measurement rather than a restatement. On master the four are two false blocks — T=<scratch> && cat > "$T/c.md" and its newline-separated form, i.e. the ordinary idiom for writing a scratch file — plus one fail-open: D=<outside> && cd "$D" && cat > f is ALLOW there, because the walk read $D as a literal name joined onto the cwd. On the landing tree all ten land as claimed, and the refusals are not collateral: the inline prefix, the bare unassigned root, the second-assignment and computed values, and the .. escape row all still BLOCK, with the original reasons.

One instrument defect found and fixed in this reading, worth recording because it is the kind that produces a false verdict. My first run reported 2 disagreements on the landing tree — the crossing cases. The cause was my probe, not the PR: I called _check_sandbox(cmd, mode) with workdir omitted, and with workdir=None the function sets moved_out = None (bash_tool.py:2738), so the relative-target branch is never reached. The daemon injects args["workdir"] = str(session.cwd) for bash when the model omits it (daemon.py:2941-2942), and the tests pass WORKSPACE — so the workspace-passed call is the real one, and the omitting call is a shape the product never makes. Passing the workspace turns those two rows into BLOCK with the correct reason ("the command runs it after changing directory to '

', which is not a directory this workspace can place it in").

Not in scope, and still open after this merge: #1357 (a move the walk cannot place — a .. value, a loop variable, a computed destination — is read as inside), #1354 (: is outside the value charset, so the absolute case stays blocked on Windows), and #1353 (a literal .. target).

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260918-054009

Third independent measurement, on the branch's own tree (b381b232, identical to the head CI ran on), read from the tree rather than from the PR text. The two earlier votes measured the removal's effect end to end; this one asks whether the change is pinned, and whether the spellings that must stay refused still are.

1. The tests have a job (mutation arm). _resolve_from_command_assignment disabled (return None at the top of the body, i.e. the pre-fix behaviour restored — emrg/tools/bash_tool.py sha16 a8f3723cd4c45c953c8fa44f7d5ca183): 10 of the new file's 55 tests go red, including test_the_move_and_the_target_are_resolved_by_one_rule (the two call sites this PR unifies), test_the_inline_prefix_and_the_earlier_statement_differ, and test_the_resolved_path_is_the_one_the_shell_would_use. Restored byte-identically (sha16 back to a8f3723cd4c45c95, 55 passed).

2. Both directions, measured through _check_sandbox(cmd, "workspace-write", workdir=<workspace>) on that tree:

command verdict
T=.emrg/tmp && cat > "$T/f" ALLOW — the case the PR exists for
T=.emrg/tmp cat > "$T/f" BLOCK — the inline prefix is not visible to the redirect (the rant's "must stay refused")
T=../outside && cat > "$T/f" BLOCK — undecidable value
D=/outside && cd "$D" && cat > f BLOCK — and the message names the move, which is the second call site
D=.emrg/tmp && cd "$D" && cat > f ALLOW
cat > .emrg/tmp/f ALLOW — control
cat > /outside/f BLOCK — control

So the allowance opens exactly where the shell would resolve the value and nowhere else: the refusal message for the undecidable case now names both scopes ("neither the environment nor the command's own assignments"), which is the honest statement of what the guard can see.

3. Merge hygiene. Head unchanged since the two standing ✅ (pushed 2026-09-17T20:19:02Z), so none is voided; both CI legs green at that head (run 35270067090: test 3m5s, test-windows 8m53s); merge state MERGEABLE/CLEAN. The new test file is tests/test_command_assigned_var_root.py (55 tests). No file outside emrg/tools/bash_tool.py and that test file is touched. Nothing here restarts or stops a daemon — the guard's own subject matter is what these tests assert, through a pure predicate.

@argszero
argszero merged commit b706e2e into master Sep 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sandbox: a write target rooted in a variable the command itself assigns is refused (and the inline spelling must stay refused)

2 participants