Skip to content

Commit 92febd8

Browse files
alicodingclaude
andauthored
feat: every secret read leaves an audit line, visible in Secrets (goal 0203 S3) (#432)
* feat: every secret read leaves an audit line (goal 0203 S3, backend) A sibling of the MCP call audit trail's own store shape, never its table: internal/adapters/secretaudit (pure types) and internal/adapters/secretauditstore (its own secret_access table in the execution SQLite file) record who read a vault entry, when, and why -- entry id + denormalized label, context, run/workflow ids when in-run, outcome. Instrumented seams: SecretService.ResolveSecretValue (the choke point every vaultref resolver call site funnels through -- mcp-server-spawn, exec-env, http-header, each tagged by its own caller), ListMCPServerTools' Configure-page preview path (S2's own found gap, closed here: it spawns a real MCP server outside any workflow run, previously invisible), and the UI's RevealSecret/ CopySecretToClipboard. RedactKnownSecrets stays deliberately unaudited -- it enumerates the vault only to scrub output on a failure path, never exposing a value. composition.SecretAccessRun threads RunID/WorkflowID from a node's own ExecContext through the three lookup seams (lookupMCPServerFn/ lookupExecEnvFn/lookupHTTPRequestFn) so an in-run read is attributable to the run that triggered it; every non-run caller (RefExists' dangling-reference check, static graph validation) passes the zero value, which is never an error. Two service-level test files prove the full composed path (a real SecretService, a real ConfigureService, wired via the exact SetSecretResolver seam production uses) drives each seam to exactly one audit record with the right context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq * feat: Secrets view gains Access history (goal 0203 S3, frontend) SecretsAccessHistoryDialog is one component for both entry points: the Secrets view header opens it with every read across the vault, newest first; the detail dialog's own "Access history" footer button opens it filtered to that one entry. Copy in user vocabulary ("Read by workflow <name>", "Shown to you", "Copied to the clipboard"), relative time via the existing shared formatUpdated helper, Primer ActionList mirroring ActivityMCPCalls' own audit-log shape. Read-only, server-paged against the new SecretService.ListSecretAccess RPC. secrets.spec.ts extended: reveal + copy each produce a visible Access-history row, from both the per-entry and global dialogs. Incidental: regenerating bindings for the new RPC also picked up already-merged, previously-uncommitted binding drift (guardrail.Rule's BuiltIn/Seed fields, goal 0207's settingssvc doc updates) -- the three frontend call sites constructing a bare Rule literal needed the two new required fields to keep tsc green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent fc5c6a0 commit 92febd8

59 files changed

Lines changed: 1740 additions & 153 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/models.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
22
// This file is automatically generated. DO NOT EDIT
33

4+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5+
// @ts-ignore: Unused imports
6+
import * as seedorigin$0 from "../seedorigin/models.js";
7+
48
/**
59
* Effect is a rule's (or the final evaluation's) verdict. Ordered by
610
* precedence: deny always beats ask, ask always beats allow, no matter
@@ -97,4 +101,19 @@ export interface Rule {
97101
* references a rule by ID).
98102
*/
99103
"Source": string;
104+
105+
/**
106+
* BuiltIn marks a seeded example rule (BuiltIn() below) -- purely
107+
* informational, same as mcpserver.MCPServer.BuiltIn/execenv.
108+
* ExecEnv.BuiltIn: drives a "built-in" badge only, never gates
109+
* Edit/Delete.
110+
*/
111+
"BuiltIn": boolean;
112+
113+
/**
114+
* Seed is this rule's seed provenance (docs/goals/0037) -- zero
115+
* value means "not of seed origin," migration-free. See
116+
* composition.Workflow.Seed's doc comment for the full reasoning.
117+
*/
118+
"Seed": seedorigin$0.Origin;
100119
}

frontend/bindings/github.com/alicoding/mill/internal/services/configuresvc/configureservice.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,20 @@ export function DeleteMCPServer(id: string): $CancellablePromise<void> {
236236
return $Call.ByID(3847603582, id);
237237
}
238238

239+
/**
240+
* DeriveSecretLabels answers, statically from a node's own type and
241+
* config -- never by resolving a real secret VALUE -- which vault
242+
* entries its execution will resolve, as sorted, deduped LABELS (goal
243+
* 0203 S2). This must compute the exact same answer a real run's own
244+
* vault-reference resolution (vaultref.go) would reach: WorkflowVerdicts
245+
* (the canvas nothing-hidden badge) calls this before anyone runs the
246+
* workflow, so a step that will actually touch a secret can never show
247+
* a clean badge the live gate then contradicts.
248+
*/
249+
export function DeriveSecretLabels(nodeTypeID: string, config: { [_ in string]?: string } | null): $CancellablePromise<string[] | null> {
250+
return $Call.ByID(3138098164, nodeTypeID, config);
251+
}
252+
239253
export function ExecEnvs(): $CancellablePromise<execenv$0.ExecEnv[] | null> {
240254
return $Call.ByID(1392956673);
241255
}

frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ export function CreateRule(rule: guardrail$0.Rule): $CancellablePromise<guardrai
4040
* DeleteRule removes a rule by ID; deleting an absent rule is a no-op,
4141
* matching every other Configure entity's delete semantics. Returns the
4242
* persist error (rather than swallowing it, docs/goals/0025 item 1) and
43-
* restores the deleted rule if the store write fails.
43+
* restores the deleted rule if the store write (or, for a built-in
44+
* rule, the tombstone write, goal 0203 S2) fails.
4445
*/
4546
export function DeleteRule(id: string): $CancellablePromise<void> {
4647
return $Call.ByID(1475597571, id);

frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@ export {
77
};
88

99
export type {
10+
ListSecretAccessRequest,
11+
ListSecretAccessResponse,
12+
SecretAccessRecord,
1013
Status
1114
} from "./models.js";

frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/models.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,46 @@
11
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
22
// This file is automatically generated. DO NOT EDIT
33

4+
/**
5+
* ListSecretAccessRequest is the bound read API's request shape --
6+
* EntryID empty means "no filter" (the Secrets view's global Access
7+
* history list); set means "this one entry's own history" (the detail
8+
* dialog's own filtered view). Mirrors mcpauditsvc.ListMCPCallsRequest's
9+
* own shape.
10+
*/
11+
export interface ListSecretAccessRequest {
12+
"entryId": string;
13+
"limit": number;
14+
"offset": number;
15+
}
16+
17+
/**
18+
* ListSecretAccessResponse carries one page plus the total matching-row
19+
* count, same "showing X-Y of Z" reasoning ListMCPCallsResponse gives.
20+
*/
21+
export interface ListSecretAccessResponse {
22+
"records": SecretAccessRecord[] | null;
23+
"total": number;
24+
}
25+
26+
/**
27+
* SecretAccessRecord is the frontend-facing JSON shape for one audit
28+
* row -- mirrors secretaudit.Record with JSON tags added, same
29+
* "adapter type stays free of a frontend-JSON concern" reasoning
30+
* mcpauditsvc.MCPCallRecord's own doc comment gives.
31+
*/
32+
export interface SecretAccessRecord {
33+
"id": number;
34+
"timestamp": string;
35+
"entryId": string;
36+
"label": string;
37+
"context": string;
38+
"runId": string;
39+
"workflowId": string;
40+
"outcome": string;
41+
"errorText": string;
42+
}
43+
444
/**
545
* Status is VaultStatus's return shape -- the one read the frontend
646
* needs to decide which of "set up," "unlock," or "browse" to show.

frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/secretservice.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import * as $models from "./models.js";
2727
* but ONLY if the clipboard still holds exactly that value at that
2828
* point (the same "don't clobber something the user copied since"
2929
* check KeePassXC's own auto-clear makes), never unconditionally.
30+
* Records one ContextUICopy audit line (goal 0203 S3), same not-gated-
31+
* but-visible posture as RevealSecret.
3032
*/
3133
export function CopySecretToClipboard(id: string): $CancellablePromise<void> {
3234
return $Call.ByID(2769882406, id);
@@ -55,6 +57,19 @@ export function GeneratePassword(length: number, upper: boolean, lower: boolean,
5557
return $Call.ByID(2111800646, length, upper, lower, digits, symbols);
5658
}
5759

60+
/**
61+
* ListSecretAccess is the bound read API the Secrets view's Access
62+
* history list calls -- newest first, optionally filtered to one entry,
63+
* limit/offset paged. Returns an empty page (never an error) when no
64+
* audit store is wired yet -- structurally unreachable in the real app
65+
* (main.go wires OpenAudit before any window/frontend exists), kept
66+
* graceful rather than surfacing a confusing error on a race that can't
67+
* actually happen.
68+
*/
69+
export function ListSecretAccess(req: $models.ListSecretAccessRequest): $CancellablePromise<$models.ListSecretAccessResponse> {
70+
return $Call.ByID(2793339806, req);
71+
}
72+
5873
/**
5974
* ListSecrets returns every entry's masked Summary (no password) --
6075
* the browse surface's own list, sorted by title
@@ -81,6 +96,12 @@ export function LockVault(): $CancellablePromise<void> {
8196
* good way to surface a SECOND error about redaction failing while
8297
* already reporting a first one -- text passes through unredacted
8398
* rather than the whole error path failing outright.
99+
*
100+
* Deliberately unaudited (goal 0203 S3 contract): this reads every
101+
* vault entry on a failure path purely to SCRUB output, never to expose
102+
* a value to anyone -- recording it would bury real reads (a workflow
103+
* that actually used a credential) under one audit line per error
104+
* message formatted anywhere in the app.
84105
*/
85106
export function RedactKnownSecrets(text: string): $CancellablePromise<string> {
86107
return $Call.ByID(2598084233, text);
@@ -92,7 +113,9 @@ export function RedactKnownSecrets(text: string): $CancellablePromise<string> {
92113
* browsing), matching SetHTTPRequestSecret's own write-only-elsewhere
93114
* posture but inverted: this vault's whole point is a human can read
94115
* their own password back, unlike the write-only integration-secret
95-
* slots.
116+
* slots. Records one ContextUIReveal audit line (goal 0203 S3) -- a
117+
* human's own click, not gated (S2 contract), but visible in their own
118+
* Access history.
96119
*/
97120
export function RevealSecret(id: string): $CancellablePromise<secret$0.Entry> {
98121
return $Call.ByID(1414222929, id);

frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/settingsservice.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@ export function AutoUpdateCheck(): $CancellablePromise<boolean> {
6868

6969
/**
7070
* CheckForUpdates asks the configured provider (GitHub Releases,
71-
* alicoding/mill) whether a newer version exists.
71+
* alicoding/mill) whether a newer version exists. The Wails-bound RPC
72+
* surface (no context parameter is bindable); checkForUpdates below
73+
* does the real work against a caller-supplied context, letting the
74+
* background loop (settingsservice_updatenotice.go) propagate its own
75+
* cancellable context instead of a fresh context.Background() call
76+
* happening underneath it.
7277
*/
7378
export function CheckForUpdates(): $CancellablePromise<$models.UpdateCheckResult> {
7479
return $Call.ByID(3825907183);
@@ -494,7 +499,11 @@ export function SetAttentionIdleThreshold(seconds: number): $CancellablePromise<
494499
}
495500

496501
/**
497-
* SetAutoUpdateCheck persists the opt-in; applies at boot.
502+
* SetAutoUpdateCheck persists the opt-in and applies it live (goal
503+
* 0207): turning it on starts the background loop immediately if it
504+
* isn't already running; turning it off stops it. Both directions are
505+
* idempotent -- flipping the same value twice is a no-op the second
506+
* time, never a second loop or a panic on double-stop.
498507
*/
499508
export function SetAutoUpdateCheck(on: boolean): $CancellablePromise<void> {
500509
return $Call.ByID(3808971894, on);

frontend/e2e/secrets.spec.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,21 @@ test('secret manager: create vault, store/reveal/copy/edit/history/delete a pass
9999
await expect(page.getByTestId('secret-detail-copied').or(page.getByTestId('secret-detail-error'))).toBeVisible()
100100
})
101101

102+
// --- Access history (goal 0203 S3): the reveal (and, when the real
103+
// clipboard write succeeded, the copy) just performed each leave a
104+
// visible row, in the user's own vocabulary, from the entry's own
105+
// filtered view. Scoped to the currently-open detail dialog: the
106+
// Secrets view header's own global Access history button carries
107+
// the identical accessible name, still present (unmounted) behind
108+
// this modal. ---
109+
const bankDetailDialog = page.getByRole('dialog', { name: 'Bank of Testing', exact: true })
110+
await bankDetailDialog.getByRole('button', { name: 'Access history' }).click()
111+
const entryAccessHistory = page.getByRole('dialog', { name: /Access history for/ })
112+
await expect(entryAccessHistory).toBeVisible()
113+
await expect(entryAccessHistory.getByText('Shown to you')).toBeVisible()
114+
await entryAccessHistory.getByLabel('Close').click()
115+
await expect(bankDetailDialog).toBeVisible()
116+
102117
// --- Edit: change the password ---
103118
await page.getByRole('button', { name: 'Edit' }).click()
104119
const editPassword = page.getByTestId('secret-password-input')
@@ -112,14 +127,32 @@ test('secret manager: create vault, store/reveal/copy/edit/history/delete a pass
112127
await expect(page.getByTestId('secret-detail-password')).toHaveValue('second-password-fake')
113128

114129
// --- History: the pre-edit value is preserved ---
115-
await page.getByRole('button', { name: 'History' }).click()
130+
// exact: true -- "History" is otherwise a substring match against
131+
// this same dialog's own "Access history" footer button.
132+
await page.getByRole('button', { name: 'History', exact: true }).click()
116133
const historyRow = page.getByTestId('secret-history-row')
117134
await expect(historyRow).toHaveCount(1)
118135
await historyRow.getByLabel('Show password').click()
119136
await expect(historyRow.locator('input')).toHaveValue('first-password-fake')
120137
await page.getByRole('dialog', { name: /History for/ }).getByLabel('Close').click()
121138
await page.getByRole('dialog', { name: 'Bank of Testing', exact: true }).getByLabel('Close').click()
122139

140+
// --- Global Access history (Secrets view header, goal 0203 S3):
141+
// every read/reveal/copy this run performed against "Bank of
142+
// Testing" shows up, newest first, each carrying the entry's own
143+
// label -- unlike the per-entry filtered view above, which shows
144+
// context only, this list needs the label to tell entries apart. ---
145+
await page.getByTestId('secrets-access-history-open').click()
146+
const globalAccessHistory = page.getByRole('dialog', { name: 'Access history', exact: true })
147+
await expect(globalAccessHistory).toBeVisible()
148+
await expect(globalAccessHistory.getByText('Bank of Testing').first()).toBeVisible()
149+
// Copied to the clipboard vs. Couldn't be read -- environment-
150+
// dependent, same reasoning the earlier real-clipboard step's own
151+
// comment gives (headless Linux CI has no pbcopy); either one
152+
// proves the copy attempt left a row.
153+
await expect(globalAccessHistory.getByText('Copied to the clipboard').or(globalAccessHistory.getByText("Couldn't be read"))).toBeVisible()
154+
await globalAccessHistory.getByLabel('Close').click()
155+
123156
// --- Delete via the row's kebab menu, confirmed by name ---
124157
const bankRow = page.getByTestId('inventory-row').filter({ hasText: 'Bank of Testing' })
125158
await bankRow.getByTestId('inventory-row-menu').click()

frontend/src/composition/breakpoints.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export function useBreakpoints(workflowId: string | undefined, onChanged?: () =>
9797
GuardrailService.CreateRule({
9898
ID: '', Label: 'Breakpoint', Effect: GuardrailEffect.EffectAsk, Source: DEBUG_SOURCE,
9999
WorkflowID: workflowId, NodeID: nodeId, NodeTypeID: '', RequestID: '', Condition: '',
100+
BuiltIn: false, Seed: { SeedRevision: 0, Modified: false },
100101
}).then(done).catch(done)
101102
}
102103
},

frontend/src/locales/en/secrets.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,26 @@
5555
"urlLabel": "Website",
5656
"notesLabel": "Notes",
5757
"tagsLabel": "Tags"
58+
},
59+
"accessHistory": {
60+
"button": "Access history",
61+
"heading": "Access history",
62+
"headingForEntry": "Access history for \"{{label}}\"",
63+
"emptyHeading": "No reads yet",
64+
"emptyDescription": "Every time this vault is read, it shows up here.",
65+
"showingRange": "Showing {{start}}-{{end}} of {{total}}",
66+
"previousPageAriaLabel": "Previous page",
67+
"nextPageAriaLabel": "Next page",
68+
"outcomeRead": "Read",
69+
"outcomeError": "Couldn't be read",
70+
"readFailed": "Couldn't be read",
71+
"readByWorkflow": "Read by workflow \"{{label}}\"",
72+
"readMcpServerSpawn": "Read to start an MCP server",
73+
"readExecEnv": "Read to run a command",
74+
"readHttpHeader": "Read for an API call",
75+
"readConfigureToolsPreview": "Read to list an MCP server's tools",
76+
"readUiReveal": "Shown to you",
77+
"readUiCopy": "Copied to the clipboard",
78+
"readGeneric": "Read"
5879
}
5980
}

0 commit comments

Comments
 (0)