Skip to content

Fail startup when NAD_POLICY names a file that is not there - #109

Open
ColinkaMir wants to merge 2 commits into
portdeveloper:mainfrom
ColinkaMir:fix/fail-on-missing-policy-override
Open

ColinkaMir wants to merge 2 commits into
portdeveloper:mainfrom
ColinkaMir:fix/fail-on-missing-policy-override

Conversation

@ColinkaMir

Copy link
Copy Markdown
Contributor

Closes #108.

What changed

A missing policy file is only optional when nobody asked for that file. NAD_POLICY is an operator saying "use this one", so a typo in it started the agent with no limits and no allowlist: the same silently-permit-everything state a malformed file already refuses to cause.

loadPolicy could not tell the two cases apart, because both arrived as the same path argument, process.env.NAD_POLICY || DEFAULT_PATH resolved in the parameter default. The override is now read inside the function, so ENOENT is refused only when a nonempty NAD_POLICY selected the file, and an absent default still returns null. A blank override counts as no override.

A dangling symlink needs no arm of its own: the open follows the link and misses the target, so it arrives as the same ENOENT. There is a test for that rather than a comment claiming it.

It throws, and cli.mjs already catches a throw from loadPolicy, prints it and exits 1 before the wallet is initialised, so nothing new is needed at the call site and src/mcp.mjs inherits the behaviour. An explicit argument keeps its older meaning: callers that pass a path have always owned the result, and the existing suite depends on it.

One existing harness moved with it

test/native-tools-cli-exit.test.mjs pointed NAD_POLICY at an absent file to mean "no policy", which is exactly what this change turns into an error. It now writes an empty policy object and points at that, which preserves the harness's intent, a run with no rules. I am flagging it rather than burying it, since it is the one place this change reaches outside its own tests.

Verification

  • Red test: three of the eight new tests fail on main, and they are the three that encode the defect: the refusal itself, the dangling symlink, and the startup ordering. The other five pin behaviour that must not change (absent default, blank override, valid override, malformed override still reported as malformed, explicit argument unchanged) and pass on both sides.
  • The startup test asserts on the real CLI transcript: exit 1, the path in the message, and no initializing wallet line, because the ordering is the part that matters.
  • Portable paths throughout os.tmpdir() and path.join, and the symlink case skips rather than fails where the platform will not create one.
  • npm test: 535 pass, 0 fail. Five consecutive runs of the new file, no flake.

A correction from my last PR

In #107 I wrote that three failures in test/native-tools-cli-exit.test.mjs were my local Node 20 against your Node 22. That was wrong. They were a stale dist/: after npm run build the whole suite passes here. The diagnosis did not affect that change, but the claim is in its description, so I would rather say so than leave it.

Scope

Policy loading only: no spend-rule change, no transaction change, and the README sentence that said "no file means no policy" now spells out the override exception.

A missing policy file is only optional when nobody asked for that file.
NAD_POLICY is an operator saying use this one, so a typo in it started the agent
with no limits and no allowlist, which is the same silently-permit-everything
state a malformed file already refuses to cause.

loadPolicy could not tell the two cases apart because both arrived as the same
path argument: process.env.NAD_POLICY || DEFAULT_PATH was resolved in the
parameter default. The env override is now read inside the function, so ENOENT
is refused only when a nonempty NAD_POLICY selected the file, and the default
being absent still returns null. A dangling symlink needs no arm of its own:
the open follows the link and misses the target, so it arrives as the same
ENOENT, which a test now shows rather than a comment claiming it.

It throws, and cli.mjs already catches a throw from loadPolicy, prints it and
exits 1 before the wallet is touched. src/mcp.mjs follows the same rule and gets
the behaviour with it. An explicit argument keeps its older meaning, because
callers that pass a path have always owned the result and the suite depends on
that.

One existing harness had to move with this. test/native-tools-cli-exit.test.mjs
pointed NAD_POLICY at an absent file to mean no policy, which is now an error;
it writes an empty policy object instead, which keeps that harness's intent of a
run with no rules.

Three of the eight new tests fail on main: the refusal, the dangling symlink and
the startup ordering. The other five pin behaviour that must not change, and
they pass before and after.

@portdeveloper portdeveloper left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The missing-file guard works, but src/policy.mjs:32 trims the configured filesystem path before opening it. That can select a different policy and remove the intended rules.

I reproduced this with two files on Linux: policy.json contains {}, and policy.json (one trailing space) contains {"maxPerSend":"0.5"}. With NAD_POLICY set to the full path of the latter, loadPolicy() opens the former and hasRules() returns false. The old code reads the configured path literally. A whitespace-only override also now falls back to the default, although #108 requires a nonempty explicit override to fail if its file is missing.

Please preserve the exact nonempty environment value when selecting the file. Only an unset or empty override should use the optional default. Add a regression for a path with significant whitespace and cover a whitespace-only missing path; keep the missing default optional. The current "empty or blank override" test passes an explicit path argument, so it never exercises the environment-selection branch it claims to cover.

The existing 535 tests and Linux/Windows CI pass, but they do not cover this case.

… empty

Review catch, and a real one: trimming NAD_POLICY before opening it can select a
different file. On Linux 'policy.json ' and 'policy.json' are two paths, so a
configured file carrying whitespace was swapped for its neighbour, and a policy
with rules could quietly become one without any. The value is now used exactly
as configured.

The blank case moves with it. A whitespace-only override is not 'no override',
it is a path somebody typed, so it takes the missing-file refusal like any other
nonempty value. Only a completely empty or unset variable falls back to the
optional default.

The test that claimed to cover this passed an explicit path argument, which
takes the other branch entirely and proved nothing about environment selection.
It is replaced by three that drive the environment: an empty override falling
back, a whitespace-only override refused, and the reviewer's own pair of files
one trailing space apart, asserting that the file named is the file opened and
that its rules survive. That last one skips on Win32, where the platform strips
trailing spaces from path components.

Both new cases fail on the previous commit.
@ColinkaMir

Copy link
Copy Markdown
Contributor Author

Fixed in 2cf68e2, and you were right on all three points.

The trim was mine and it was wrong for exactly the reason you give: the configured value is a filesystem path, and trimming it opens a different file. NAD_POLICY is now used byte for byte as configured, so your pair of files one trailing space apart selects the one that was named and keeps its rules. Only a completely empty or unset variable falls back to the optional default.

The blank case followed from the same mistake. A whitespace-only override is a path somebody typed, not an absence, so it now takes the missing-file refusal like any other nonempty value, which is what #108 asks for.

On the vacuous test: you are right, it passed an explicit path argument and therefore never touched the environment branch it claimed to cover. It is replaced by three that drive the environment instead: an empty override falling back to the optional default, a whitespace-only override refused, and your own case with policy.json and policy.json asserting both that the file named is the file opened and that its rules survive. The last one skips on Win32, where the platform strips trailing spaces from path components, so the Windows job stays honest rather than green by accident.

Both new cases fail on the previous commit, and the suite is 537 passing.

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.

Fail startup when an explicit NAD_POLICY path is missing

2 participants