Skip to content

probe: take the capability handle from a file, not from argv - #16

Closed
iceteaSA wants to merge 1 commit into
cortexkit:masterfrom
iceteaSA:feat/probe-handle-file
Closed

iceteaSA wants to merge 1 commit into
cortexkit:masterfrom
iceteaSA:feat/probe-handle-file

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

--handle ckh_... puts a bearer capability in the process command line. --handle-file <path> reads it in-process instead, so argv carries only a path.

Why argv is the wrong place for it

A capability handle is a bearer secret — presenting it is the authorization. argv is not private:

  • /proc/<pid>/cmdline is world-readable
  • ps output, for any local user
  • shell history
  • CI job logs
  • an agent's tool transcript, which is transmitted off the machine to a model provider and copied into compaction summaries and child contexts

That last one is the asymmetry that matters here. The handles file on this box is mode 600 and same-user, so reading it crosses no trust boundary. The same bytes in a transcript have left the host and cannot be recalled. A local file read and an argv leak are not the same exposure.

Measured on this vault: zero ckh_ occurrences across all 11 monitoring logs, including a prefix-only scan for ckh_ plus eight characters, across 45 probe invocations using the file form.

Deliberate choices, each with the failure mode it avoids

Both flags is an error, not a precedence rule. A caller passing both has one of the two wrong. Silently preferring either can send a handle the caller did not mean, which against a vault is an unintended credential read.

An unreadable or empty file exits 2 rather than falling through. This is the one worth arguing for. An empty handle would reach the vault and return the same uniform not_found the read surface uses for a revoked or unknown handle — deliberately uniform, to prevent enumeration. So a local file error would present as an authorization verdict, and the two are indistinguishable in the probe's output. Failing loud at the file keeps a local error local.

The content is trimmed. A handle written with > or by an editor carries a trailing newline, and a handle with a newline in it is simply a different string.

--handle is unchanged and still works.

Verification

cargo check -p credentials-module --example vault_read_probe --locked    rc=0

Six arms exercised against the built example:

arm result
valid file, trailing newline parsing succeeds, fails later at the connection file — the positive control
nonexistent file rc=2 · cannot read --handle-file <path>: No such file or directory
empty file rc=2 · --handle-file <path> is empty
both flags rc=2 · pass --handle OR --handle-file, not both
neither flag rc=2 · --handle <ckh_...> or --handle-file <path> is required
no print site carries the handle verified — only a doc comment and two literal-text error messages mention it

The first arm is the one that could silently not work: a check that only ever exits 2 would pass every negative arm while breaking the feature. It fails at the connection file instead, which proves handle resolution ran and succeeded.

The last arm exists because the file form would be pointless if the probe echoed the handle somewhere — worth confirming rather than assuming.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

`--handle ckh_...` puts a bearer capability in the process command line. argv is
not private: it is in shell history, in `ps` output, in `/proc/<pid>/cmdline`
which is world-readable, in CI job logs, and -- when an agent drives the probe
-- in the tool transcript, which is transmitted off the machine to a model
provider and copied into summaries and child contexts. The file at mode 600
crosses no trust boundary; the same bytes in a transcript have left the host.

`--handle-file <path>` reads the handle in-process instead, so argv carries only
a path. Verified on this vault: zero `ckh_` occurrences across all 11 monitoring
logs, including a prefix-only scan for `ckh_` plus eight characters, across 45
probe invocations.

Deliberate choices, each with its failure mode:

- Both flags is an ERROR rather than a precedence rule. A caller passing both
  has one of the two wrong, and silently preferring either can send a handle the
  caller did not mean -- against a vault, that is an unintended credential read.
- An unreadable or empty file EXITS 2 rather than falling through. An empty
  handle would reach the vault and come back as the same uniform `not_found`
  used for a revoked handle, so the probe would report an authorization verdict
  for what is actually a local file error -- indistinguishable in the output.
- The content is trimmed. A handle written with `>` or by an editor carries a
  trailing newline, and a handle with a newline in it is a different string.

`--handle` is unchanged and still works.

Verified: cargo check rc=0, and six arms exercised against the built example --
valid file (parsing succeeds, failing later at the connection, which is the
positive control), missing file, empty file, both flags, neither flag, and a
check that no print site in the probe carries the handle value, since the file
form would not help if one did.
@ualtinok ualtinok closed this Aug 27, 2026
@ckcred-alfonso

Copy link
Copy Markdown

Your change is on master as d7f96a4, with your authorship intact. It did not get there by merging this PR, and the reason is a mistake of mine that I want on the record rather than tidied away.

What happened

I merged #14, then fired the merge for this PR seconds later. GitHub recomputes mergeability asynchronously after the base moves, so this PR was still mergeable: UNKNOWN — not conflicted, just not yet evaluated. The merge call failed against that unknown state, and the PR ended up closed and unmerged, closed by my own identity at 18:10:26Z.

I could not reopen it: gh pr reopen is unclassified in our routing manifest and was refused, and that refusal is correct — it is not a declared operation. So the choice was another signing ceremony for a one-line recovery, or landing the commit directly.

What I did instead

git cherry-pick -x of 13dce7d, which preserves you as author:

author     iceteaSA <171169159+iceteaSA@users.noreply.github.com>
committer  ualtinok

Before doing it I confirmed the work was not at risk: refs/pull/16/head persists on this repository independently of the fork, so your commit was recoverable regardless of the PR's state. That check came first, because "the PR is closed" and "the work is gone" are very different problems and I did not want to act on the wrong one.

The review, since it should not be lost with the PR

The reasoning I most wanted to keep is the asymmetry: a same-user read of a mode-600 file crosses no trust boundary, while the same bytes in an agent's tool transcript have left the host and cannot be recalled. That is the argument, and it is not the one I would have reached for — I would have said "argv is world-readable" and stopped at /proc.

I checked the edges that usually break this kind of change, and all three are handled deliberately rather than incidentally:

  • trims the trailing newline a handles file always ends with (this exact defect bit --payload-file here once);
  • refuses an empty file rather than sending an empty handle — which matters more than it looks, because the vault answers an empty handle with the same uniform not_found it gives a revoked one, so the failure would have been indistinguishable from a real revocation;
  • errors when both flags are passed instead of picking a precedence, which is right: a caller passing both has one of the two wrong, and silently choosing either can send a handle they did not mean.

What this cost you, and what I changed

You lose the merge attribution on this PR — the commit is yours, the merge is not. I am sorry for that; it was my race, not your branch.

Two things changed as a result. I will not fire a merge immediately after another one lands on the same base without letting mergeability settle first. And your design doc from #11 turned out to trip our doc-status gate, which demanded a local symbol whose existence would falsify a NOT-BUILT claim — impossible for a design whose implementing half lives in another repo. Rather than point the marker at a plausible local symbol that does not govern the claim, the gate now accepts an explicit EXTERNAL declaration that must name where the falsifier lives, and prints those separately on every run as claims it cannot check. A guard that fires on the wrong event is worse than an absent one, because it reads as coverage.

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.

2 participants