26 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 647 | pi-permission-system: permission review logs may persist secrets with inherited file modes |
Owner-only log file modes and key-name redaction
Release Recommendation
Release: ship independently
No roadmap step in packages/pi-permission-system/docs/architecture/architecture.md references #647, and the doc carries no Release: batch annotations at all — the flat phase list was slimmed out in #601.
The change lands as fix: commits, which cut a patch release for @gotgenes/pi-permission-system at ship time.
Problem Statement
#647 is a third-party report from marcoscale98 against release pi-permission-system-v20.10.0.
It makes three claims about the permission review log, which is enabled by default:
- Bash decisions persist the complete command string.
- Generic tool decisions persist a serialized input preview up to the configured log limit.
- Log files are appended without an explicit mode, so their permissions follow the process umask.
All three are accurate.
Measured on the operator's own machine at planning time, ~/.pi/agent/extensions/pi-permission-system/logs/pi-permission-system-permission-review.jsonl is 6.7 MB across 8380 lines with mode -rw-r--r-- (0644) — world-readable on a shared host.
The report proposes two remedies: redact common secret forms before persistence, and create the files owner-only.
Because the issue is third-party, the proposal was treated as a request to evaluate rather than a spec.
An ask_user gate established the direction: the file-mode hardening lands as proposed, and the log-content half lands as key-name masking only — the boring, standard, zero-maintenance technique — with value-shape secret detection explicitly declined.
Goals
- Both JSONL logs and the logs directory are created owner-only (
0600/0700), and an already-created log inherited from an earlier version is tightened on next write. - Permission-forwarding request and response JSON files are created owner-only, and their directories
0700. - A value bound to a sensitive key name is masked with
[redacted]before it is persisted to either log stream, including thetoolInputPreviewfield that carries a generic tool's serialized input. - The permission ask-prompt continues to display the unredacted input — the user must see what is actually being run to make a permission decision.
- The reasoning, the threat model, and the declined alternatives are recorded as ADR 0010 so the next reporter's identical issue can be triaged against a written contract.
- No new configuration field is introduced.
This change is not breaking.
The file-mode change tightens permissions on a diagnostic artifact with no documented consumer contract.
The redaction changes the content of that artifact, but the docs have never guaranteed verbatim payloads, and the masked values are precisely the ones no consumer has a legitimate need to read.
The commits are fix:, not fix!:.
Non-Goals
- Value-shape secret detection is declined, not deferred.
No
sk-/ghp_/AKIA/Bearerprefix list, no entropy heuristic, no regex over the serialized line. ADR 0010 records why; see Design Overview. - Redacting the bash
commandfield is out of scope. A command string has no keys, so key-name masking cannot reach it. The grammar-anchored alternative (masking the value side of avariable_assignmentin the existing tree-sitter parse) was costed and declined for this issue; it is recorded in ADR 0010 as the option a future report would reopen, not as filed work. - No
registerLogRedactordownstream seam. A fourth registry mirroringToolInputFormatterRegistry/ToolAccessExtractorRegistry/AuthorizerRegistrywould ship with zero consumers, which is exactly what the package's maintenance-trap rule targets. config-store.tsconfig writes keep their current mode. The savedconfig.jsonholds permission policy, not secret payloads. It was offered in the direction gate and deliberately not selected.- The forwarding request/response payloads are not redacted, only mode-restricted. The parent reads those files to render the ask-prompt, so masking them would break the prompt the same way masking the prompt path would.
- No change to
permissionReviewLog's default, and no newlogToolInput-style opt-in.
Background
Where the payload comes from
ToolPreviewFormatter.getPermissionLogContext (src/tool-preview-formatter.ts) builds four fields for every review entry: command, target, toolInputPreview, origin.
The toolInputPreview value for a non-path-bearing tool comes from formatGenericToolInputForLog, which calls serializeToolInputPreview (src/tool-input-preview.ts) → safeJsonStringify (src/logging.ts) and truncates at TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH (1000).
The critical structural fact: the tool input is flattened to a string before it reaches the logger.
PermissionPrompter.writeReviewEntry (src/authority/permission-prompter.ts:135) hands logger.review a record of flat scalars, and toolInputPreview is already "input {\"authorization\":\"Bearer …\"}" by then.
So a redaction pass applied only at the log-write boundary would not catch the reporter's own repro.
Redaction has to happen while the input is still an object.
That gives two application points, and the plan uses both:
formatGenericToolInputForLog— the only place the generic tool input is still structured.writeLineinsrc/logging.ts— the persistence choke point, covering both streams and any future call site that logs a nested object.
Where the files come from
src/logging.tswriteLinecallsensureLogsDirectory()thenappendFileSync(path, line, "utf-8")— no mode on either.src/extension-config.tsensurePermissionSystemLogsDirectorycallsmkdirSync(logsDir, { recursive: true })— no mode.src/authority/forwarding-io.tsensureDirectoryExistscallsmkdirSync(path, { recursive: true }), andwriteJsonFileAtomiccallswriteFileSync(tempPath, …, "utf-8")thenrenameSync.renamepreserves the temp file's mode, so setting the mode at temp-file creation is sufficient there — no chmod needed.
ForwardedPermissionRequest (src/authority/permission-forwarding.ts:109) carries message, surface, and value — the flattened display strings the parent renders — so these files hold the same command text as the log.
Constraints from AGENTS.md and the package skill
- Do not read
process.platforminsidesrc/; an ESLintno-restricted-syntaxguard blocks it and onlyindex.tsis exempt. The design therefore performs no platform branching —chmodis attempted unconditionally and its failure is swallowed. - Do not park session-scoped mutable state at module level; it now persists across same-cwd session switches.
The "already hardened this session" set lives in the
createPermissionSystemLoggerclosure, which is rebuilt per session. - CI runs
ubuntu-latestonly (.github/workflows/ci.yml), so POSIX mode assertions in tests are safe.
Design Overview
Why key names and not value shapes
The direction gate was settled against measured evidence from the live 6.7 MB review log:
| Probe | Hits | What they actually are |
|---|---|---|
sk- |
403 | 356 task-approval, 275 task-user, 146 task-no-ui — substrings of task-*. The 4 genuine sk-ant-oat… shapes are inside a grep pattern the agent typed, not a key. |
xox |
2 | Inside the tool-use id toolu_01VdGtvuHfmxox86kCCkY3. |
API_KEY |
8 | The literal env-var name ANTHROPIC_API_KEY in grep commands; no value. |
Bearer , ghp_, github_pat_, AKIA, AIza, password |
0 | — |
Anchoring would fix those specific false positives, but the corpus also contains zero true positives, so the shape list would be pure maintenance burden with an unstatable failure boundary. A redactor that silently misses a key is worse than a documented warning, because it invites treating the log as safe to share. This mirrors the stance already recorded for #599 / ADR 0007, where a hard-coded secret denylist was declined because the codebase has no formal secrets model.
Key-name masking has the opposite profile: it is structural rather than predictive, so it has no false-positive tail worth worrying about (a key literally named password holding a non-secret is not a real concern), and it has a boundary statable in one sentence.
It is also what the logging ecosystem actually ships — pino's redact, Winston's format pipeline, and Serilog's destructuring policies are all declarative key-path masking, not detection.
Decisively, it catches the reporter's literal repro: their step 2 is a generic tool call with a field authorization: "Bearer TEST_VALUE", and that field is a real object key at the moment formatGenericToolInputForLog runs.
Module shape
Step 1 is a preparatory tidy.
safeJsonStringify currently lives in src/logging.ts but is imported by src/tool-input-preview.ts for prompt serialization, which has nothing to do with logging.
Extracting it makes the change easy: the redacted variant then has an obvious home, and the two test files that mock #src/logging to get at it have to move their mock target anyway.
// src/json-safe-stringify.ts — extracted in step 1
type ReplacerTransform = (key: string, value: unknown) => unknown;
/** Shared replacer: Error → plain object, bigint → string, cycles → "[Circular]". */
export function createJsonSafeReplacer(
transform?: ReplacerTransform,
): (key: string, value: unknown) => unknown;
export function safeJsonStringify(value: unknown): string | undefined;
// src/log-redaction.ts — new in step 2
export const REDACTED_PLACEHOLDER = "[redacted]";
/** True when a log key names a credential-bearing value. */
export function isSensitiveLogKey(key: string): boolean;
/** `safeJsonStringify` with sensitive-keyed values masked. */
export function redactedJsonStringify(value: unknown): string | undefined;
The transform is a predicate collaborator, not a boolean flag, so the two entry points share one traversal without threading a discriminator.
Masking inside the replacer means the nested structure below a sensitive key is never visited, and the existing WeakSet cycle guard is reused unchanged — no second walk, no separate cycle handling.
The key set is fixed and narrow: authorization, api_key / apiKey / api-key, secret, token, password, passwd, credential, cookie, private_key / privateKey.
Matching is case-insensitive and substring-based within the key only, so ANTHROPIC_API_KEY and x-api-key both match.
A null or undefined value is left alone rather than replaced with the placeholder, so an absent field does not read as a suppressed one.
// src/log-file-permissions.ts — new in step 4
export const OWNER_ONLY_FILE_MODE = 0o600;
export const OWNER_ONLY_DIRECTORY_MODE = 0o700;
/**
* Best-effort tightening of an existing path's mode. Never throws:
* on Windows `chmod` only toggles the read-only bit and may reject a
* directory outright, and a hardening failure must not break the gate.
*/
export function restrictExistingPathToOwner(path: string, mode: number): void;
Consumer call sites
The logger closure gains one small piece of per-session state so the upgrade-path chmod runs once per file rather than once per line:
// src/logging.ts — inside createPermissionSystemLogger
const hardened = new Set<string>();
// inside writeLine, after a successful append:
appendFileSync(path, `${line}\n`, { encoding: "utf-8", mode: OWNER_ONLY_FILE_MODE });
if (!hardened.has(path)) {
hardened.add(path);
restrictExistingPathToOwner(path, OWNER_ONLY_FILE_MODE);
}
The mode option applies only when appendFileSync creates the file, which is why the chmod is needed at all — the operator's existing 6.7 MB log would otherwise stay 0644 forever.
The directory is handled inside ensurePermissionSystemLogsDirectory, which is stateless and already called on every write; it gains a mode on mkdirSync plus an unconditional restrictExistingPathToOwner, trading one syscall per log line for not needing a second piece of closure state.
Edge cases
mkdirSync(logsDir, { recursive: true, mode })applies the mode to every directory it creates, so on a fresh install~/.pi/agent/extensions/pi-permission-system/also becomes0700. That is desirable — it holdsconfig.json. Directories that already exist are untouched, so~/.pi/agentis never modified.modeis masked by the process umask at creation.0600 & ~0022is still0600, so a default umask is harmless, and thechmodcovers any umask that would have widened it.- A
chmodfailure is swallowed with no user-facing warning. On Windows the call is a near-no-op and could otherwise emit a benign warning every session; the log there is governed by NTFS ACL inheritance, which this change does not attempt to manage. ADR 0010 states that explicitly rather than leaving it implied. - A path-bearing tool's log preview goes through
formatToolInputForPrompt, which produces human-readable summaries with no JSON, so redaction is a no-op there — except for a custom formatter registered viaToolInputFormatterRegistry, whose output is an opaque string this change cannot mask. Noted in the ADR as a residual. writeJsonFileAtomicwrites to${filePath}.${pid}.${now}.tmpand renames. Setting the mode atwriteFileSyncis sufficient becauserenamepreserves it, and a response file overwritten later goes through a fresh temp file.
Module-Level Changes
New files
packages/pi-permission-system/src/json-safe-stringify.ts—createJsonSafeReplacer+safeJsonStringify, moved out oflogging.ts.packages/pi-permission-system/src/log-redaction.ts—REDACTED_PLACEHOLDER,isSensitiveLogKey,redactedJsonStringify.packages/pi-permission-system/src/log-file-permissions.ts— mode constants +restrictExistingPathToOwner.packages/pi-permission-system/test/json-safe-stringify.test.ts— characterization tests for the moved behavior (cycles,Error,bigint), which no test covers today.packages/pi-permission-system/test/log-redaction.test.ts— key predicate + masking, including the nested and array cases and thenull-value guard.packages/pi-permission-system/test/log-file-permissions.test.ts— mode tightening on an existing file and directory; a nonexistent path does not throw.packages/pi-permission-system/docs/decisions/0010-permission-log-secret-exposure.md— ADR 0010.
Changed files
src/logging.ts— dropssafeJsonStringify(re-homed);writeLineserializes withredactedJsonStringify, appends withmode: OWNER_ONLY_FILE_MODE, and tightens the file once per session via a closure-heldSet.src/extension-config.ts—ensurePermissionSystemLogsDirectorypassesmode: OWNER_ONLY_DIRECTORY_MODEtomkdirSyncand callsrestrictExistingPathToOwner.src/tool-input-preview.ts— importssafeJsonStringifyfrom the new module; gainsserializeRedactedToolInputPreview.src/tool-preview-formatter.ts—formatGenericToolInputForLogswitches toserializeRedactedToolInputPreview;formatJsonInputForPromptis left on the unredacted path deliberately.src/authority/forwarding-io.ts—ensureDirectoryExistspassesmode: OWNER_ONLY_DIRECTORY_MODE;writeJsonFileAtomicpassesmode: OWNER_ONLY_FILE_MODEon the temp write.test/tool-input-preview.test.ts—vi.mock("../src/logging.js", …)retargets to../src/json-safe-stringify.js.test/tool-preview-formatter.test.ts— same mock retarget, plus the new redaction and prompt-stays-unredacted cases.test/logging.test.ts— extended with file/directory mode assertions and a redaction case.test/authority/forwarding-io.test.ts— mode assertions on a written request file and its directory.
Documentation
docs/configuration.md— thepermissionReviewLogtable row (line 104) gains a sensitivity note; the "Additional behaviors" bullet about boundedtoolInputPreviewvalues (line 906) is reworded to state that sensitive-keyed values are masked and bash command strings are not.docs/troubleshooting.md— the Threat Model "Limitations" list (line 39 onward) gains an entry stating that the review log records bash command strings verbatim, that files are owner-only, and thatpermissionReviewLog: falseis the lever for a session handling credentials.docs/architecture/architecture.md— the module tree gains the three new modules near the existinglogging.ts/tool-input-preview.tsentries. Thelogging.tsentry is reworded from "JSONL review/debug log writer" to note owner-only creation and the redaction pass..pi/skills/package-pi-permission-system/SKILL.md— a short note under Configuration or Testing recording that log writes are owner-only and key-name redacted, so a future change does not reintroduce a raw write path.
Grep verification performed at planning time
safeJsonStringifyimporters:src/tool-input-preview.ts, plustest/tool-input-preview.test.tsandtest/tool-preview-formatter.test.ts, which mock it by relative path (../src/logging.js), not the#src/alias — an alias-only grep would have missed both.appendFileSync/writeFileSync/mkdirSynccall sites insrc/: five total, three in scope (logging.ts,extension-config.ts,forwarding-io.ts×2), two deliberately excluded (config-store.ts).permissionReviewLogin user-facing docs:docs/configuration.mdlines 39, 57, 104;config/config.example.json;src/config-schema.ts. NoREADME.mdhit, so no README section goes stale.- No roadmap step, health-metric row, or Mermaid node references #647, so there is no
✅step-mark to land.
Test Impact Analysis
- What the extraction enables that was previously impractical.
safeJsonStringify's cycle,Error, andbiginthandling is currently untested — both consumers mock it away, andlogging.test.tsonly exercises the toggle behavior. Moving it to its own module gives it a natural test home, and step 1 adds the characterization tests before the move so the move is verifiably behavior-preserving.restrictExistingPathToOwnerandisSensitiveLogKeyare likewise directly unit-testable as pure/near-pure functions. - What becomes redundant.
Nothing.
The existing
logging.test.tscase covers the enable/disable toggles, which no new test duplicates. - What must stay as-is.
test/tool-preview-formatter.test.ts's prompt-formatting cases genuinely exercise the layer being changed and must keep asserting unredacted prompt output — they are the regression guard for the invariant below.
Invariants at risk
- The ask-prompt shows the unredacted input.
This is the package's core function: a user cannot make a permission decision about input they cannot see.
Currently pinned only implicitly by
test/tool-preview-formatter.test.ts's prompt cases, none of which use a sensitive key. Step 3 adds an explicit test — same input, sensitive key present, prompt unmasked and log masked in one assertion pair. safeJsonStringifysurvives the move byte-identically for cycles,Errorvalues, andbigint. Measured at planning time: no test asserts any of the three (grep -rn "safeJsonStringify\|Circular\|bigint" test/returns only the two mock files). Step 1 adds them before the move, so the move is guarded rather than assumed.- A log IO failure stays non-fatal and is surfaced once via
PermissionSessionLogger.reportOnce. The newchmodmust not re-enter that path — it swallows its own errors and returns nothing, sowriteLine'sstring | undefinedwarning contract is unchanged. - The gate never blocks on a hardening failure.
restrictExistingPathToOwnercannot throw, so no new failure mode reachescreateFailClosedToolCall.
TDD Order
refactor:extract JSON-safe stringification. Test surface: newtest/json-safe-stringify.test.ts. First add characterization tests for cycles /Error/bigintagainstsafeJsonStringifyin its current home (red only in the sense that the file is new; they pass immediately), then move the function plus a newcreateJsonSafeReplacerintosrc/json-safe-stringify.ts, updatesrc/logging.tsandsrc/tool-input-preview.ts, and retarget the twovi.mockcalls. Removing the export fromlogging.tsbreaks every importer at the type level, so the move and all consumer + consumer-test updates are one commit. Commit:refactor(pi-permission-system): extract JSON-safe stringify from logging.fix:mask sensitive-keyed values at the log-write boundary. Test surface: newtest/log-redaction.test.tsplus a case intest/logging.test.ts. Red: a review entry whose details carry a nested{ headers: { authorization: "Bearer TEST_VALUE" } }is written verbatim. Green:src/log-redaction.tslands andwriteLineserializes throughredactedJsonStringify. Covers the predicate, nested objects, arrays, thenull-value guard, and non-sensitive keys passing through untouched. Wiring lands in the same commit as the module so no unconsumed export exists forfallow dead-codeto flag. Commit:fix(pi-permission-system): mask sensitive-keyed values in permission logs.fix:mask the generic tool-input preview. Test surface:test/tool-preview-formatter.test.ts. Red: the reporter's repro — a generic extension tool called with{ authorization: "Bearer TEST_VALUE" }produces a log preview containing the value. Green:serializeRedactedToolInputPreviewinsrc/tool-input-preview.ts, wired intoformatGenericToolInputForLog. The paired assertion pins the invariant: the same input throughformatToolInputForPromptstays unredacted. Commit:fix(pi-permission-system): redact generic tool input in the review log.fix:create permission logs owner-only. Test surface: newtest/log-file-permissions.test.tsplus mode assertions intest/logging.test.ts. Red: after a review write into a temp dir,statSync(reviewLogPath).mode & 0o777is not0o600, and the logs directory is not0o700; a pre-created 0644 file stays 0644 across a write. Green:src/log-file-permissions.ts, themodeoptions inlogging.ts/extension-config.ts, and the once-per-sessionchmod. Commit:fix(pi-permission-system): create permission logs owner-only.fix:create permission-forwarding artifacts owner-only. Test surface:test/authority/forwarding-io.test.ts. Red: a request written viawriteJsonFileAtomicis 0644 and its directory 0755. Green: themodeoptions inensureDirectoryExistsandwriteJsonFileAtomic. Commit:fix(pi-permission-system): create forwarding request files owner-only.docs:record the decision and refresh the user docs. ADR 0010, the twodocs/edits, the architecture module-tree entries, and the SKILL.md note. Commit:docs(pi-permission-system): record ADR 0010 on permission-log secret exposure.
Risks and Mitigations
- False confidence in the redaction.
A user could read "logs are redacted" and paste a log containing a bash command with an inline secret.
Mitigated by stating the boundary in the same sentence everywhere it appears — in
docs/configuration.md, in the troubleshooting Threat Model limitation, and in ADR 0010 — as "a value bound to a sensitive key name is masked; a secret embedded in a bash command string is not." - Over-redaction of a legitimately non-secret key.
A key named
tokencarrying a parser token would read[redacted]in the log, hurting debuggability. Accepted: the key set is fixed and narrow, and the debug stream's structured fields (toolName,action,reason,matchedPattern,origin) are all outside it. - Windows behavior differs.
chmodthere toggles only the read-only bit, and themodeoptions are ignored, so the change is POSIX-effective only. Mitigated by swallowing the failure silently rather than warning every session, and by stating the limitation in ADR 0010. CI isubuntu-latest, so the mode assertions are deterministic there. mkdirSyncmode applied to a newly created parent. On a fresh install the extension's config directory becomes0700rather than umask-default. Judged desirable, since it holdsconfig.json; existing directories are never modified, so no upgrade regresses an operator's chosen layout.- The step-1 move silently dropping a consumer.
Both mocking test files use a relative specifier, so the
#src/alias grep the project convention suggests would miss them. Mitigated by the explicit relative-path grep recorded above and by running the full suite, not just the cycle-scoped file — the mock returns a partial module, so a missed retarget surfaces asundefined is not a functionat run time rather than atscerror.
Open Questions
- Whether ADR 0010 should also state a position on the debug stream's
permission.decisiontraces, which duplicate the payload whendebugLogis on. The redaction covers them automatically because both streams sharewriteLine, so this is a wording question rather than a scope question; resolve while drafting the ADR. - Whether the sensitive-key set belongs in
log-redaction.tsas a module constant or eventually as a documented, overridable list. Landing it as a constant now; revisit only if a concrete report names a key the fixed set misses.