Agent-First Data v0.32.0: Where the Value Comes From
A credential on argv is in the process table, the shell history, and the audit log. Every CLI eventually grows a private answer to that. This release makes where a value comes from part of what an argument declares, so help, validation, and reading all agree.
A registry could say an argument was a string, that it was required, that it was a UUID or an integer in a range. It could not say the one thing every credential-taking flag needs to say: that the value does not have to be typed on the command line.
So the answer lived somewhere else. In afdata’s own CLI it was --secret-from stdin|prompt|fd:N|env:VAR — a flag with its own parser, its own capped reader,
its own terminal-echo guard, its own /dev/tty prompt, all private to one
binary. It worked. It was also 141 lines that said nothing to the registry, so
--help could not describe it, validation could not reject a bad spelling
before the command ran, and no other program could reuse a line of it.
That is the shape of the problem. Not that reading a secret from a file is hard, but that “where does this value come from” was being answered below the argument grammar, where nothing that reasons about the grammar can see it.
The source is part of the declaration
An argument now declares the sources it accepts:
ArgSpec::option("--password-secret", "SOURCE").sources(SourceSet::stream())
SourceSet::config() is the two sources a value can come from with neither a
terminal nor a pipe — an environment variable, or an address inside a document.
That is the set most arguments want. SourceSet::stream() adds stdin,
fd:N, and prompt, for a value that may be piped in, handed over on an
inherited descriptor, or typed by a person.
The grammar is small and closed:
env:NAME an environment variable
file:PATH#DOT_PATH an address inside a JSON/TOML/YAML/dotenv/INI file
file+FORMAT:PATH#DOT_PATH the same, naming the parser
stdin the whole of standard input
fd:N an inherited descriptor
prompt /dev/tty, echo suppressed
literal:VALUE the escape hatch
A bare argument is still the literal value. literal: exists only for a value
that would otherwise be read as one of the prefixes above — an escape, not a
style.
Because the registry knows the set, three things follow without the application
doing anything. Help prints the accepted syntax next to the argument. A
recognized scheme outside the declared set is rejected as
cli_invalid_argument_value — exit 2, a usage error, before config,
filesystem, terminal, network, or domain I/O. And the whole thing serializes
into cli-spec-v1, so a compiler in another language can implement the same
argument from the spec alone.
One rule falls out of that and is worth stating: an argument with sources cannot declare a default. A default is a value. Making it an instruction to go read the host’s environment or filesystem would mean a program that reads a file nobody asked it to read.
Reading it is a separate act, with a type
Classification and reading are deliberately not the same step. The registry decides what the source is; the application reads it only when the command it selected actually needs the value.
// The registry has already proved this spelling is a source the argument
// accepts; classifying it again cannot fail on a value that got this far.
let source = sources.parse(invocation.required("password_secret").as_str().unwrap())?;
let password = source.read_secret()?;
read() returns a String. read_secret() returns a SecretString, whose
Debug and Display are ***, and whose contents are reachable only through
an explicit expose_secret(). The distinction is not cosmetic: the secret path
refuses a non-string value rather than coercing it, and its errors carry
nothing that could echo back what was read. On the plain path, quoting the
file and the parser’s complaint is the whole point of the error. On the secret
path it is a leak.
The Rust adapter adds one rule the core cannot: prompt on an argument whose
id does not end in _secret is refused at build time. Prompting blocks on a
terminal and suppresses echo. Both are correct for a password and wrong for
anything else, and the argument’s own name is the only place that fact is
already written down.
A host may add a scheme this crate does not implement — a secrets manager, a
container lookup — by declaring a name and its syntax. That teaches help and
validation the source exists; the host does the reading. The name is checked
against the reserved env, file, stdin, fd, prompt, and literal, and
against the host’s own other schemes, so a host source can never quietly shadow
a built-in.
The file it would not read
file: was worth very little if it could not open the files that exist.
The commonest configuration-file shape in the world is a flat key=value list
with no section header at all. /etc is full of them. So is the preamble of
every php.ini. The INI editor answered all of them with root entries are not supported, because it had been built on one uniform depth: every value at
section.key, no exceptions.
The uniformity was not worth the files it excluded. Entries before the first
[section] are now the document root, addressed by bare key. That introduces
exactly one ambiguity — a root key and a section of the same name would address
the same top-level name — and it is refused rather than resolved, as a parse
error beside the duplicate-section rule it resembles. Writing is where this
gets subtle: save emits root entries first, and set inserts a new one before
the first header. Not by preference. A key=value written after a header
belongs to that header’s section, so anywhere else is a silent move of the
author’s data.
The other half of the problem is that such a file is rarely named .ini. It is
named .conf, or .cfg, or nothing at all. file+FORMAT:PATH#DOT_PATH lets
the source say what the filename cannot:
$ tool auth --password-secret 'file+ini:/etc/app/app.conf#http-password'
The +FORMAT sits before the colon so that a Windows drive letter is never
mistaken for it. And the CLI core never resolves the word: cli_spec decides
argv grammar and is not permitted to know what a document format is, so it
carries the caller’s spelling and the value-source layer turns it into a
parser — and rejects an unknown one there, where the format table actually
lives. That table, Format::from_cli_name, is now the single one; the afdata
CLI’s --input-format reads from it instead of keeping a second copy that
could drift.
The CLI eats its own cooking
--secret-from still exists, with the same spelling and the same behavior. It
is now four lines over the shared library instead of a private implementation
of it, and the two library messages that differed from the CLI’s shipped
wording moved to the CLI’s, not the reverse — those strings are released, and
its e2e pins them.
It kept its own flag rather than becoming .sources() on set’s VALUE
positional, and the reason is a good illustration of what this facility is for.
If a general value argument accepted sources, then
$ afdata set notes.json mood prompt
would block on a terminal waiting for someone to type a mood. literal: would
stop being an escape hatch and start being a trap you have to remember. Per
invocation, explicitly opted into, is what the flag is for. file: stays out of
its set for the same reason: set is already pointed at a document, and a
second document address in the same call reads as an operation on that file.
What this release was about
The last release was about failures routed somewhere they could not be answered. This one is about a fact routed somewhere it could not be read.
“This value may come from an environment variable” was always true of these
arguments. It was written in a private parser, so --help could not say it,
validation could not enforce it, the spec could not carry it to another
language, and every program that needed it wrote it again. Moving it up into
the declaration did not add a capability. It made an existing one visible to
everything that had a reason to know.