Replace the latchkey skill with connect-external-service, and add the secret card - #638
Draft
gnguralnick wants to merge 51 commits into
Draft
gnguralnick wants to merge 51 commits into
gnguralnick wants to merge 51 commits into
Conversation
with_secrets.py is the one sanctioned reader of data/.secrets/<name>.env: it loads the file's variables into a child process's environment and execs the command. agent_secrets_guard.sh refuses any other read or write of the directory, in shell commands (tokenized, with bash -c strings unwrapped so a supervisord program passes) and in the file tools of claude, codex, and pi. The filing checker now counts a run of request_secret.py as a filing, so a secret request stands alone in its tool call exactly as a permission request does. Registered for claude, codex, pi (as a payload checker over every tool call), and the agy shim; documented as P8 and P9. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the request script An agent asks for a credential by running the connect-external-service skill's request_secret.py, which files a request through the chat app's new POST /api/secret-requests and prints the filed request. The chat app persists requests under data/.state/secret-requests/, supersedes a pending request for the same file, and on submit merges the values into data/.secrets/<file>.env (mode 0600, POSIX single-quoted, read back byte for byte by with_secrets.py) before telling the agent the file and the variable names through the message path. Every parser recognises the call from its input and lifts the echoed request out of its result, and the classifier reads the stored / declined / superseded notice by its tag, so the frontend can render a card from structured fields.
A secret request renders as a card with one password input per variable, the agent's rationale, the target file, and a note naming the variables a submit overwrites; Submit and Decline (with an optional note) post straight to the chat app, and the card flips to stored, declined, or superseded from the transcript's notice, from the page's own submit, or from a status fetch after a reload. The transcript walk lifts a secret request out of any open step exactly as it does a permission request, resolves each card by its own request id, and marks an earlier pending card for the same file superseded when a later request appears.
One skill routes every external-service request: a routing table with one row per method (builtin latchkey service, custom latchkey service, MCP server, direct API with a key from the secret card, browser), each with its priority, the cheap test that says whether it is workable, the sentence to say, and the reference to read. The latchkey text becomes references/latchkey.md (with the custom-service header and the row-4 pointer in place of the type-it-into-a-file advice); references/mcp.md and references/direct-api.md are new. The eight skills, AGENTS.md, the build-app planning prompt, the card's doc comment, and the guard test comments point at the new skill; the browser-fleet skill's worktree correction, the use-ai-integration delete-and-re-snapshot wording, the build-app --secrets-file option and program shape, the service-processes and scheduled-task wrapper rules, and the worker's report-not-request rule ride along.
…ence at it The skill's SKILL.md is a routing table with one row per method (builtin latchkey service, custom latchkey service, MCP server, direct API with a key from the secret card, browser): its priority, the cheap test that says whether it is workable, the sentence to say, and the reference to read. The latchkey text lives on as references/latchkey.md (with the custom-service header and a pointer to row 4 in place of the type-it-into-a-file advice); references/mcp.md and references/direct-api.md are new. The eight skills that named the latchkey skill, AGENTS.md, the build-app planning prompt, the card's doc comment, and the guard test comments point at the new skill. Alongside: the browser-fleet skill's worktree correction, use-ai-integration's delete-and-re-snapshot wording, build-app's --secrets-file option and program shape, the service-processes and scheduled-task wrapper rules, and the worker's report-not-request rule.
An app declares the env files it runs under in app.toml ([[secrets]]: file, variables, note) and a skill under secrets: in its SKILL.md front matter. publish-template's manifest writer aggregates those, plus every data/.secrets/<file>.env the snapshot's .mcp.template.json and supervisord programs name, into [[requirements.secret]] entries (file, variables, note; the legacy bare name still loads) and the matching requires_secret: lines in template.md, and refuses to assemble when a referenced file has no declaration or a declared variable is missing from the publishing workspace's own file. The validator flags a program running under an undeclared file, and an included .mcp.json ships renamed to .mcp.template.json so nothing activates on adoption before its secrets exist. use-template files one secret card per entry up front and merges each entry's servers into .mcp.json once its file is stored.
…d drop section banners The routes were closures over the router's two capabilities; they are now plain view functions that read a SecretRequestChatBridge the router attaches to the app state (the interface lives beside the store so the state can import it without a cycle), with a recording bridge as the test double. The guard test's awkward value keeps its hash without tripping the trailing comment ratchet, and the section banners are gone from every file this branch touches.
…ice-fallback-skill
…the chat app's state The skill, the secrets README, and policy P8 said the guard "refuses every other read", which promises more than a text-level checker delivers: it cannot see what a program run under the wrapper does with its environment. Each now says it is a backstop against a slip and that the rule is the agent's to keep. Secret-request records move from data/.state/secret-requests/ to data/.apps/chat/secret-requests/, where the rest of the chat app's state lives. The transcript recogniser for a secret request now requires the script as a whole word followed by its --file, the same "an argument, not a mention" test the P9 checker applies, so a Read or Grep of the script no longer renders a waiting card.
Problem: build_template.sh read $REQUIRES_SECRET_LINES 31 lines before the variable was assigned and before write_template_manifest.py produced the file, so under set -euo pipefail every assembly aborted with an unbound-variable error (and without -u the lines would never have reached template.md, failing the validator's markdown/TOML agreement check for any template with a secret). Fix: move the read to just before the template.md heredoc, after the writer has run; the scanner-gated assembly test now declares a secret in the demo app and asserts the entry lands in both template.toml and template.md.
Problem: the comment sweep removed a leading '--' from lines that were not banners: build_template.sh comments now named the options include, data-include and skip-apt-check (the flags are --include, --data-include and --skip-apt-check) and lost a bullet's indentation, and a mid-sentence dash in claude/session_parser.py and a parenthetical's closing dash in turn-grouping.ts left ungrammatical comments. Fix: put the three flag names and the bullet indentation back, and rewrite the two prose lines with a semicolon and a parenthesis so a later sweep cannot repeat the damage.
Problem: test_a_program_running_under_an_undeclared_secret_file_is_flagged constructed its declared manifest through a conditional expression whose condition was a mkdir() side effect and whose else branch could never run, then asserted the result was not None. Fix: create the directory on its own line and assign the manifest directly.
Problem: _find_echoed_object serves both the permission-request and the secret-request probe, but its warning on absurd JSON nesting still said 'permission-request probe', misleading whoever reads the log for a secret request. Fix: name it an echoed-request probe.
…xt render Problem: hydrateStatus in secret-card.ts deleted the cache entry on a 5xx or a network error and then called m.redraw(); that redraw re-entered knownSecretResolution, which found no entry and fetched again, so while the chat app was down every pending secret card fetched back to back. Fix: cache a failed entry with a retry time (15 s, as latchkey-scope-info.ts does), refetch only once it passes, and schedule one redraw at that time so an idle page still retries; a test pins that a 502 is not refetched on the next render and is after the delay.
Problem: agent_secrets_guard_check.py allowed any segment whose program was the wrapper without looking past its --, so 'with_secrets.py data/.secrets/svc.env -- cat data/.secrets/svc.env' passed the P8 guard and printed the file into the transcript. Fix: the words after the wrapper's -- are judged as a command of their own (recursively, like a bash -c string), the P8 policy text and the secrets README say so, and the bypass is in the blocked corpus.
Problem: agent_latchkey_request_check_test.py nested a double quote inside a double-quoted f-string, syntax Python only accepts from 3.12; the repo targets 3.11, so ruff reports invalid-syntax and the lint gate fails. Fix: hoist the removesuffix into a module constant and interpolate that.
…s written by hand Problem: build-app/SKILL.md read as if --secrets-file wrote both the app.toml [[secrets]] declaration and the wrapped program command, but scaffold_flask_lib.py only wraps the command (it cannot know the variables), so an app scaffolded on that reading fails publish-template's declaration check. Fix: state what the flag does and that the declaration is added by hand either way.
Problem: the README's list of API route families stopped at /api/latchkey after the branch added the secret-request routes. Fix: name /api/secret-requests there with what it serves.
Problem: build_template.sh read the writer's requires-secret-lines file with '2>/dev/null || true', so a missing file would silently drop the requires_secret: lines from template.md and surface later as a markdown/TOML disagreement. Fix: read it plainly; under set -e a failed substitution stops the script at the real cause.
Problem: the secret-echo assertion decoded its fixture with __import__("json").loads, an obscure spelling of a plain import.
Fix: import json at the top and use json.loads.
Problem: write_template_manifest.py declared SecretDeclaration as a hand-written class whose four-argument __init__ only stored its fields. Fix: a stdlib @DataClass with the same fields, so the script stays runnable under a bare python3.
Problem: system/scripts/README.md names every utility script in the directory, but the new with_secrets.py (the one sanctioned reader of data/.secrets/, referenced by supervisord programs, .mcp.json, cron jobs, and three skills) was missing from the list. Fix: added it to the utility-scripts bullet with a one-line description pointing at the secrets guard and the connect-external-service skill.
Problem: UnknownSecretRequestError mixed in KeyError, whose __str__ returns the repr of its argument, so the 404 detail the submit and decline routes build with str(e) came out wrapped in a stray pair of quotes. Fix: mix in LookupError instead, which keeps the not-found meaning with the default __str__; the endpoint test now asserts the plain detail text.
…hook Problem: secret-card.ts exported resetSecretStatusCacheForTesting, a test-only hook whose sole purpose was to clear the module-level status map between vitest cases; production code should carry no test hooks. Fix: move the cache and its two operations onto a SecretStatusCache class (known, note) with one module-level instance for the live card; the tests build their own instances and need no reset.
Problem: scaffold_flask_lib.py spliced --secrets-file straight into the supervisord program's bash -c string as data/.secrets/<name>.env without checking it against the slug every other reader of that directory enforces, so a name with a space, quote, slash, or uppercase letter was written into the program command and only failed later at publish or process start. Fix: validate the name up front with the same grammar and exit with the scaffold's usual "error: --secrets-file ..." message, with a test that runs the real script and checks nothing is written.
Problem: tool_output.py declared SECRET_REQUEST_SCRIPT as the one basename the chat and the P9 checker key on, but the matcher on the next line restated request_secret.py as a literal and nothing read the constant. Fix: compile _SECRET_REQUEST_SCRIPT_RE from re.escape(SECRET_REQUEST_SCRIPT).
Problem: one line of the guard's "backstop, not a boundary" paragraph in data/.secrets/README.md ran to about 170 characters in a file wrapped at 80. Fix: rewrap the paragraph's last two sentences; no wording change.
…ice-fallback-skill # Conflicts: # system/apps/chat/imbue/chat/server.py # system/libs/app_manifest/src/app_manifest/manifest.py # system/libs/app_manifest/src/app_manifest/primitives.py
Problem: every name validator on the secret-request path (secret_requests.py's SecretFileName and SecretVariableName, request_secret.py's --file and --var, write_template_manifest.py's declaration check, scaffold_flask_lib.py's --secrets-file) tested its `^...$` pattern with re.match, and `$` also matches just before a trailing newline. A variable named "FOO\n" was accepted and written into data/.secrets/<file>.env as a bare "FOO" line followed by "='value'", which with_secrets.py refuses to parse -- so the whole file, and every other credential already in it, stopped loading for every program running under it. A file named "svc\n" became the path data/.secrets/svc\n.env. Fix: use fullmatch at each site, which is the check every one of those docstrings already describes, and add the two trailing-newline cases to the store's malformed-filing corpus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: format_secret_resolution_notice took verdict as a bare str, branched on it with if/elif/else plus a SwitchError default, and the module repeated the vocabulary as SECRET_RESOLUTION_VERDICTS -- a third spelling after SecretRequestStatus and the frontend's SecretResolution. The three callers passed string literals, so nothing checked the spelling and a fourth verdict would have compiled while falling through. docs/system/style_guide.md asks for a match statement with assert_never when branching on a finite set of values. Fix: add SecretResolutionVerdict (LowerCaseStrEnum, same wire spellings), derive SECRET_RESOLUTION_VERDICTS from its members, dispatch with match + assert_never, and pass the enum from the submit, decline and supersede routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: agent_latchkey_request_check.classify() was extended to count a run of request_secret.py as a filing (policy P9), and the module docstring, the refusal wording and the wrapper were all reworded, but classify()'s own docstring still said it judged only "a permission request" -- understating the function's scope by half for anyone tracing where a block came from. Fix: name both kinds of filing, matching the module docstring's wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: `_files_secret_request` in system/scripts/agent_latchkey_request_check.py counted any shell word whose basename was `request_secret.py` as a filed secret request, with nothing to corroborate that the segment ran the script. The permission half of the same checker requires the POST method alongside the URL, and the chat's reader of the same shape (`is_secret_request_call` in tool_output.py) requires the script's `--file` -- its docstring claims the two apply the same test. They did not, so `grep -rn request_secret.py .agents | wc -l`, `cat <the script> && echo done` and `<the script> --help 2>&1` were all hard-blocked with "file ONE request per tool call" for commands that file nothing. Fix: a segment counts as a filing only when the script word is followed by its required `--file` (either spelling argparse accepts), matching the card reader. The corpus gains the three inspection commands as allowed cases; every blocked case already carries the flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: tool-call-policies-state-of-things.md still said "three rows are `partial`" after the diff added P8 (agy partial) and P9 (codex and agy partial) to the at-a-glance table, which brings the count to five. The file's header makes keeping the table current part of wiring a policy. Fix: say five. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: .agents/skills/connect-external-service/scripts/request_secret.py had no test beside it, though main() takes argv/environ/clock/sleep purely so a test can drive it and file_request() implements a bounded retry against a chat app answering 503. Nothing exercised the retry window, the chat-id fallback, the run-outside-an-agent-shell exit, or the branch that lifts the chat app's own reason out of a refusal; the subprocess test beside the chat app covered the happy path and an unreachable app only. Fix: add request_secret_test.py for the units (argument refusals, chat-id resolution, the retry window through a fake clock, the no-chat-id exit), and add one case to test_secret_requests.py that files against a chat the app does not know, so the refusal path reports the chat app's detail rather than dumping its answer. Both retry tests and the refusal test were confirmed to fail against a broken retry loop and a dropped detail lift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: POST /api/secret-requests caught only InvalidSecretRequestError, but SecretRequestStore.file_request() also reads the current data/.secrets/<file>.env to report existing_variables and overwrites, and that read raises SecretFileWriteError when the file exists and cannot be opened. The exception escaped the view, so the agent got a bare 500 with no detail instead of the error body every other route on this module returns -- request_secret.py prints "refused the request (HTTP 500): None". Fix: catch it and return a 500 with the store's message, the way submit() already does. The message names the path and never a value. A test files against a mode-000 env file and was confirmed to raise without the catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: system/scripts/agy_shim/bash still carried an ASCII banner above the soft-policy section, in a file this branch edits (the guard loop gained agent_secrets_guard.sh). Banners come out of every file a diff touches, and the branch already swept them from every other file it touches. Fix: keep the sentence, drop the rule and the trailing dashes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: `uv run ruff format --check` reported drift in the two test files this iteration added lines to, while the same files were clean on origin/main. The pre-commit hook did not reformat them on the way in. Fix: run `uv run ruff format` on the two files. No behaviour change; the tests still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ice-fallback-skill # Conflicts: # .agents/skills/latchkey/SKILL.md
Problem: the branch deletes .agents/skills/latchkey/ in favour of connect-external-service, but migrate_workspace.py's `retired-skill` audit patterns -- which exist for "references to skills that were renamed, which a migrated skill or doc will otherwise send the agent looking for" -- were not extended. A source workspace's own docs saying "the `latchkey` skill" or naming .agents/skills/latchkey/SKILL.md went unreported; the `latchkey` audit kind only matches CLI calls and gateway URLs, not the skill. Fix: added a path pattern (skills/latchkey) and a prose pattern (latchkey` skill) to the retired-skill kind, with a test that both spellings are flagged and a plain `latchkey curl` line is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: system/apps/chat/imbue/chat/harnesses/tool_output.py -- find_permission_request was generalised into _find_echoed_object, which now serves the secret-request path too, but its three constants kept permission-only names and comments (_PERMISSION_REQUEST_ID_KEY, _MAX_PERMISSION_REQUEST_LENGTH, _MAX_PERMISSION_REQUEST_PROBES). A reader of the secret path could not tell whether those bounds applied to it. Fix: renamed them to _REQUEST_ID_KEY, _MAX_ECHOED_REQUEST_LENGTH and _MAX_ECHOED_REQUEST_PROBES, reworded their comments to say echoed request, and updated the one test that imports the probe cap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: swapping "the `latchkey` skill" for the full path .agents/skills/connect-external-service/references/latchkey.md pushed four prose lines to 102-126 columns in .agents/skills/minds-api/SKILL.md and .agents/skills/migrate-workspace/SKILL.md, whose paragraphs otherwise wrap at ~80. Fix: refilled those four paragraphs. Wording unchanged; only line breaks move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: SecretRequestStore._write_env_file writes every value into data/.secrets/.<file>.env.tmp before renaming it into place. A failure in the write, the chmod or the rename was turned into SecretFileWriteError and returned, leaving that copy of the values on disk indefinitely -- a file the wrapper will not load (it takes only .env), the user will not see (a dotfile), and nothing else cleans up. Fix: unlink the temporary in a finally, so a success (where the rename already moved it) is a no-op and a failure leaves nothing behind; a failure to unlink is logged without a value and does not mask the real error. Added a test that forces the rename to fail and asserts the secrets directory holds no leftover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: the same substitution fixed in minds-api and migrate-workspace left long lines in five more ~80-column-wrapped skills: use-template (two), github-sync, publish-template, update-published-template and update-installed-template. Fix: refilled those paragraphs. Wording unchanged; only line breaks move. AGENTS.md is left alone -- its paragraphs are single unwrapped lines already. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: the test boots chat-app --preflight as a subprocess and polls its health route with a 30s wait_for, then allows the shutdown up to 15s in its finally. The chat suite's pytest-timeout default is 10s, so the harness cap sat well below the test's own declared budget: on a loaded machine the boot took 10.3s and the test was killed mid-wait, while it passes in under 4s on a quiet one. The failure was a property of the cap, not of the code. Fix: mark the test with an explicit 60s timeout, covering the boot wait and the shutdown it already budgets for. Not marked flaky: the cause is known and this removes it rather than retrying around it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: test_path_watcher_stop_is_idempotent failed once during a loaded, parallel run of the whole chat suite. The cause is not established: stop() joins a watchdog observer thread, and the suite's 10s pytest-timeout can catch that join under contention, but 30 solo runs did not reproduce it and solo runs are not the condition it failed in. Fix: mark it flaky so offload retries it, and record what is and is not known beside it. Unlike the pre-flight boot test, there is no declared budget here that the cap undercuts, so raising a timeout would be guessing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: test_path_watcher_stop_is_idempotent was marked flaky on the grounds that, unlike the pre-flight boot test, there was no declared budget for the 10s cap to undercut. There is one. PathWatcher.stop() joins the watchdog observer for up to 5s and then the watcher thread for up to 5s, and the test calls stop() twice, so the body declares up to 20s against the suite's 10s func-only cap. Over 50 runs the background thread never reached _ensure_observers() before the first stop(), so the observer was always created during it and the second stop() is the one that stops and joins a live observer -- the join the comment already named. The flaky mark alone also changes nothing here: CI runs `uv run pytest` per package, with no offload lane and no pytest-rerunfailures to honor it. Fix: add an explicit 30s timeout covering the 20s the code under test declares, and say that in the comment instead of the session's run counts. The flaky mark stays, because the cause is still unconfirmed and removing it would claim otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ice-fallback-skill
Problem: the hydration retry test pumped a fixed 6 microtask hops and then asserted the refetched status had landed. Reading a fetch Response's body takes 4 of those 6 on macOS, so the margin was two hops, and on CI's Linux runner the read needs more than six: the test failed there with `expected null to be 'stored'` while passing locally. vi.useFakeTimers() compounds it by replacing setImmediate, so any turn of that body read which goes through a scheduler cannot run at all, and how many turns it takes is a platform detail. Fix: wait on the signal SecretStatusCache ends a hydration with -- it stores the entry and then asks for a redraw -- yielding whole scheduler turns, bounded, with a message naming the shortfall. Fake only the clock the test drives (setTimeout and Date), leaving setImmediate real. The afterEach now restores spies too: they outlive their test, so the second vi.spyOn(m, "redraw") was handing back the first test's spy with its calls still counted, which made the new wait return before the first fetch had landed. Verified by control: at three hops the old test reproduced CI's failure on the same line, and with the retry-delay gate removed the new test still fails on the refetch count rather than passing vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem: settleHydrations in secret-card.test.ts read the redraw count at the top of each iteration and yielded at the bottom, so all 100 checks preceded their yield and the outcome of the final one was never inspected. A hydration that settled on that last turn still threw, and because the message re-reads the count at throw time it reported a number satisfying its own expectation. Bisecting the bound reproduced it exactly: at `turn < 1` the retry test failed with `hydration never settled: 1 redraws, expected 1`. Fix: yield first, then check, leaving one check site. Every check now follows a yield, so the throw is reachable only when the count is genuinely short after all 100 -- which is the whole point of having a bound. Confirmed in both directions: at a bound of 1 the test now passes, and at a bound of 0 it still throws, reporting a truthful `0 redraws, expected 1`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Paired with imbue-ai/mngr-internal#1137, which carries the plan (
blueprint/external-service-fallback/plan-external-service-fallback.md) and the mngr side (the custom-serviceheader).The problem
The
latchkeyskill was the only guidance an agent had for reaching an outside service, and it ended at "ask for a new connection to a domain latchkey does not know". Past that an agent improvised: it asked the user to type a key into a file "with ample warning", or drove a browser with no guidance on when that was the right call. There was no way to take a secret from the user without the value entering the transcript.What changes
One skill,
connect-external-service, replaceslatchkey. Its SKILL.md is a routing table with one row per method (builtin latchkey service, custom latchkey service, MCP server, direct API with a key from the secret card, browser): the cheap test that says whether the row is workable, the sentence to say, and the reference to read. The agent takes the first workable row and explains rather than asking. The latchkey text isreferences/latchkey.md;references/mcp.mdandreferences/direct-api.mdare new. The eight skills that named the latchkey skill,AGENTS.md, the build-app planning prompt, and the doc comments point at it.The secret card.
request_secret.pyfiles a request through the chat app's newPOST /api/secret-requestsand prints it; every harness's parser recognises the call from its input and lifts the echoed request from its result, and the chat renders a card with one password input per variable, the rationale, the target file, and a note naming the variables a submit overwrites. Submit writesdata/.secrets/<file>.env(mode 0600, POSIX single-quoted, merged into what the file held) and tells the agent the file and variable names; Decline sends the verdict and a note; a second request for the same file supersedes the first. Requests persist underdata/.state/secret-requests/. The values reach the file and nothing else, and the tests assert that against the transcript notice, the persisted record, and the log.The wrapper and the guards.
system/scripts/with_secrets.py data/.secrets/<name>.env -- <command>is the one sanctioned reader of a secret file. Policy P8 (agent_secrets_guard.sh) refuses any other read or write of the directory, in shell commands (tokenized,bash -cstrings unwrapped so a supervisord program passes) and in claude's, codex's, and pi's file tools; P9 holds a secret request to the same one-per-tool-call rule as a permission request, in the same checker. Registered for claude, codex, the agy shim, and pi (as the first checker that runs on every tool call).Templates carry their secrets.
app.tomlgains[[secrets]]and SKILL.md front mattersecrets:;publish-templateaggregates them (plus the files.mcp.template.jsonand supervisord programs run under) into[[requirements.secret]]withfileandvariables, refuses to assemble on an undeclared reference or a variable missing from the publishing workspace, and ships an included.mcp.jsonas.mcp.template.json;use-templatefiles one card per entry and merges servers into.mcp.jsononce their file is stored.Decisions on the plan's open questions
registeredServicesentry undermindsCredentialHeader(mngr side).enableAllProjectMcpServers: truein.claude/settings.json; the Claude Code settings reference documents it as approving every project.mcp.jsonserver without a prompt.envvalues are literal and Claude expands${VAR}from the harness environment, so the references document the wrapper for every harness; pi reads.mcp.jsonthroughpi-mcp-adapter, antigravity through.agents/mcp_config.json.Verification
Unit and integration suites for
system/scripts, the pi extensions, the chat app (Python and frontend),app_manifest,env_converge, and the publish-template scripts pass locally. The guard corpus was run once against a neutered checker to confirm it fails without the guard. The build-template assembly test that exercises the.mcp.jsonrename needs the workspace image's secret scanners and runs in CI. Manual verification in a dev workspace (the plan's phase 7) has not been done yet.