claude-nomad 0.32.2 → 0.32.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/commands.push.allowlist.ts +16 -9
- package/src/commands.push.recovery.actions.ts +46 -36
- package/src/commands.redact.ts +2 -1
- package/src/config.ts +20 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.32.4](https://github.com/funkadelic/claude-nomad/compare/v0.32.3...v0.32.4) (2026-05-30)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
* **push:** honor drop-wins for allow in recovery dispatch ([#194](https://github.com/funkadelic/claude-nomad/issues/194)) ([9731ecf](https://github.com/funkadelic/claude-nomad/commit/9731ecf9b3d65a6f10fd3e7fc8e520f839640b25))
|
|
9
|
+
|
|
10
|
+
## [0.32.3](https://github.com/funkadelic/claude-nomad/compare/v0.32.2...v0.32.3) (2026-05-30)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
* **push:** hard-block sensitive never-sync files under extras ([#191](https://github.com/funkadelic/claude-nomad/issues/191)) ([6509387](https://github.com/funkadelic/claude-nomad/commit/6509387b5724d13e8aa2122eb99cdd80e58da2ee))
|
|
16
|
+
|
|
3
17
|
## [0.32.2](https://github.com/funkadelic/claude-nomad/compare/v0.32.1...v0.32.2) (2026-05-30)
|
|
4
18
|
|
|
5
19
|
|
package/package.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ALWAYS_NEVER_SYNC,
|
|
3
|
+
NEVER_SYNC,
|
|
4
|
+
PUSH_ALLOWED_STATIC,
|
|
5
|
+
SUPPORTED_EXTRAS,
|
|
6
|
+
type PathMap,
|
|
7
|
+
} from './config.ts';
|
|
2
8
|
import { isValidSharedDir } from './config.sharedDirs.guard.ts';
|
|
3
9
|
import { fail, NomadFatal } from './utils.ts';
|
|
4
10
|
|
|
@@ -22,17 +28,18 @@ function isAllowed(path: string, allowed: readonly string[]): boolean {
|
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
|
-
* True when any path segment matches a
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
+
* True when any path segment matches a hard-block entry. Outside the extras
|
|
32
|
+
* tree the full `NEVER_SYNC` set applies. Inside `shared/extras/` only the
|
|
33
|
+
* `ALWAYS_NEVER_SYNC` subset applies (Pitfall 6): the broader set was authored
|
|
34
|
+
* against `~/.claude/` semantics for ephemeral state, so `.planning/todos/` and
|
|
35
|
+
* similar legitimate GSD content must pass, but genuinely-sensitive host-local
|
|
36
|
+
* files (`.credentials.json`, `settings.local.json`, `.claude.json`, ...) stay
|
|
37
|
+
* blocked even when nested inside a synced extras dir.
|
|
31
38
|
*/
|
|
32
39
|
function isNeverSync(path: string): boolean {
|
|
33
|
-
|
|
40
|
+
const blockSet = path.startsWith('shared/extras/') ? ALWAYS_NEVER_SYNC : NEVER_SYNC;
|
|
34
41
|
for (const segment of path.split('/')) {
|
|
35
|
-
if (
|
|
42
|
+
if (blockSet.has(segment)) return true;
|
|
36
43
|
}
|
|
37
44
|
return false;
|
|
38
45
|
}
|
|
@@ -46,64 +46,65 @@ export async function collectActions(
|
|
|
46
46
|
const sid = sessionIdFromFinding(f);
|
|
47
47
|
const header =
|
|
48
48
|
`\nFinding: ${f.RuleID} in ${f.File} line ${f.StartLine}` +
|
|
49
|
-
(sid
|
|
49
|
+
(sid === null ? '' : ` (session: ${sid})`) +
|
|
50
50
|
'\n [R]edact [A]llow [D]rop session [S]kip (default)\n';
|
|
51
51
|
actions.set(findingKey(f), parseAction(await prompt(header + '> ')));
|
|
52
52
|
}
|
|
53
53
|
return actions;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Loop-invariant context for `dispatchOne`, built once by `dispatchActions`
|
|
58
|
+
* before iterating findings. Bundling these keeps `dispatchOne` to two
|
|
59
|
+
* parameters. The `redactedSids` and `droppedSids` sets are mutated in place so
|
|
60
|
+
* per-session de-duplication is maintained across the caller's loop.
|
|
61
|
+
*/
|
|
62
|
+
type DispatchCtx = {
|
|
63
|
+
findings: Finding[];
|
|
64
|
+
actions: Map<string, FindingAction>;
|
|
65
|
+
ts: string;
|
|
66
|
+
map: PathMap;
|
|
67
|
+
nowMs: () => number;
|
|
68
|
+
scan: (p: string) => Finding[] | null;
|
|
69
|
+
drop: (sid: string, map: PathMap) => boolean;
|
|
70
|
+
redactedSids: Set<string>;
|
|
71
|
+
droppedSids: Set<string>;
|
|
72
|
+
};
|
|
73
|
+
|
|
56
74
|
/**
|
|
57
75
|
* Apply one finding's triaged action against local state. Extracted from
|
|
58
76
|
* `dispatchActions` so each function stays under the cognitive-complexity gate.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* session id appears in `droppedSids`, subsequent redact or allow actions for
|
|
62
|
-
* findings in that session are skipped.
|
|
77
|
+
* Drop wins: once a session id appears in `ctx.droppedSids`, subsequent redact
|
|
78
|
+
* or allow actions for findings in that session are skipped.
|
|
63
79
|
*
|
|
64
80
|
* @param f The finding to act on.
|
|
65
|
-
* @param
|
|
66
|
-
* @param actions The action map returned by `collectActions`.
|
|
67
|
-
* @param ts Backup timestamp.
|
|
68
|
-
* @param map Parsed path-map.
|
|
69
|
-
* @param nowMs Injectable clock.
|
|
70
|
-
* @param scan Injectable scan function for `applyRedact`.
|
|
71
|
-
* @param drop Injectable staged-copy remover for the Drop action.
|
|
72
|
-
* @param redactedSids Set of already-redacted session ids (mutated in place).
|
|
73
|
-
* @param droppedSids Set of already-dropped session ids (mutated in place).
|
|
81
|
+
* @param ctx Loop-invariant dispatch context (see `DispatchCtx`).
|
|
74
82
|
*/
|
|
75
|
-
function dispatchOne(
|
|
76
|
-
f
|
|
77
|
-
findings: Finding[],
|
|
78
|
-
actions: Map<string, FindingAction>,
|
|
79
|
-
ts: string,
|
|
80
|
-
map: PathMap,
|
|
81
|
-
nowMs: () => number,
|
|
82
|
-
scan: (p: string) => Finding[] | null,
|
|
83
|
-
drop: (sid: string, map: PathMap) => boolean,
|
|
84
|
-
redactedSids: Set<string>,
|
|
85
|
-
droppedSids: Set<string>,
|
|
86
|
-
): void {
|
|
87
|
-
const action = actions.get(findingKey(f)) ?? 'skip';
|
|
83
|
+
function dispatchOne(f: Finding, ctx: DispatchCtx): void {
|
|
84
|
+
const action = ctx.actions.get(findingKey(f)) ?? 'skip';
|
|
88
85
|
if (action === 'skip') return;
|
|
86
|
+
const sid = sessionIdFromFinding(f);
|
|
87
|
+
// Drop wins: a dropped session short-circuits every later action for it,
|
|
88
|
+
// including allow, so a stale fingerprint is never written for content that
|
|
89
|
+
// was held back from the push.
|
|
90
|
+
if (sid !== null && ctx.droppedSids.has(sid)) return;
|
|
89
91
|
if (action === 'allow') {
|
|
90
92
|
applyAllow(f);
|
|
91
93
|
return;
|
|
92
94
|
}
|
|
93
|
-
const sid = sessionIdFromFinding(f);
|
|
94
95
|
if (sid === null) return;
|
|
95
|
-
if (droppedSids.has(sid)) return;
|
|
96
96
|
if (action === 'drop') {
|
|
97
|
-
droppedSids.add(sid);
|
|
98
|
-
if (drop(sid, map)) {
|
|
97
|
+
ctx.droppedSids.add(sid);
|
|
98
|
+
if (ctx.drop(sid, ctx.map)) {
|
|
99
99
|
log(
|
|
100
100
|
`dropped session ${sid} from this push (local transcript kept; the secret remains in your local copy)`,
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
|
-
if (action === 'redact' && !redactedSids.has(sid)) {
|
|
106
|
-
if (applyRedact(f, findings, ts, map, nowMs, scan))
|
|
105
|
+
if (action === 'redact' && !ctx.redactedSids.has(sid)) {
|
|
106
|
+
if (applyRedact(f, ctx.findings, ctx.ts, ctx.map, ctx.nowMs, ctx.scan))
|
|
107
|
+
ctx.redactedSids.add(sid);
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
110
|
|
|
@@ -130,10 +131,19 @@ export function dispatchActions(
|
|
|
130
131
|
scan: (p: string) => Finding[] | null = scanFile,
|
|
131
132
|
drop: (sid: string, map: PathMap) => boolean = dropSessionFromStaged,
|
|
132
133
|
): void {
|
|
133
|
-
const
|
|
134
|
-
|
|
134
|
+
const ctx: DispatchCtx = {
|
|
135
|
+
findings,
|
|
136
|
+
actions,
|
|
137
|
+
ts,
|
|
138
|
+
map,
|
|
139
|
+
nowMs,
|
|
140
|
+
scan,
|
|
141
|
+
drop,
|
|
142
|
+
redactedSids: new Set<string>(),
|
|
143
|
+
droppedSids: new Set<string>(),
|
|
144
|
+
};
|
|
135
145
|
for (const f of findings) {
|
|
136
|
-
dispatchOne(f,
|
|
146
|
+
dispatchOne(f, ctx);
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
|
package/src/commands.redact.ts
CHANGED
|
@@ -153,7 +153,8 @@ export function cmdRedact(
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
if (findings.length === 0) {
|
|
156
|
-
|
|
156
|
+
const ruleClause = rule === undefined ? '' : ` for rule ${rule}`;
|
|
157
|
+
log(`no findings${ruleClause} in session ${id}`);
|
|
157
158
|
return;
|
|
158
159
|
}
|
|
159
160
|
|
package/src/config.ts
CHANGED
|
@@ -106,19 +106,28 @@ export function allSharedLinks(map: PathMap): string[] {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
|
-
* Whitelist of names allowed in `path-map.json
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* named-extras opt-in mechanism: only entries appearing in this list are
|
|
114
|
-
* eligible for sync. Widening to include `.notes`, `.scratch`, `AGENTS.md`,
|
|
115
|
-
* etc. is a one-line edit here with no schema migration required (the field
|
|
116
|
-
* is additive on the consumer side). Mirrors `SHARED_LINKS` in shape and
|
|
117
|
-
* intent: a short, append-only `as const` tuple that downstream callers
|
|
118
|
-
* narrow against.
|
|
109
|
+
* Whitelist of names allowed in the `extras` field of `path-map.json`. Each
|
|
110
|
+
* entry is a directory (e.g. `.planning`) or root-level file (`CLAUDE.md`)
|
|
111
|
+
* copied under `shared/extras/<logical>/<name>`. Only listed names are
|
|
112
|
+
* eligible for sync; widening is a one-line edit with no migration required.
|
|
119
113
|
*/
|
|
120
114
|
export const SUPPORTED_EXTRAS = ['.planning', 'CLAUDE.md'] as const;
|
|
121
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Credential and host-config file names blocked even under `shared/extras/`,
|
|
118
|
+
* where the broader `NEVER_SYNC` segment scan is narrowed to avoid
|
|
119
|
+
* false-blocking ephemeral dir names (`todos`, `plans`, etc.) inside synced
|
|
120
|
+
* `.planning/` trees (Pitfall 6). Strict subset of `NEVER_SYNC`; doctor
|
|
121
|
+
* display and sharedDirs guard use the full set.
|
|
122
|
+
*/
|
|
123
|
+
export const ALWAYS_NEVER_SYNC = new Set([
|
|
124
|
+
'.claude.json',
|
|
125
|
+
'.credentials.json',
|
|
126
|
+
'settings.local.json',
|
|
127
|
+
'history.jsonl',
|
|
128
|
+
'stats-cache.json',
|
|
129
|
+
]);
|
|
130
|
+
|
|
122
131
|
/**
|
|
123
132
|
* Path segments that must never cross the sync boundary in either direction.
|
|
124
133
|
* Defense-in-depth pair with `PUSH_ALLOWED_STATIC`: even if the allow-list
|
|
@@ -142,8 +151,7 @@ export const NEVER_SYNC = new Set([
|
|
|
142
151
|
'statsig',
|
|
143
152
|
'telemetry',
|
|
144
153
|
'ide',
|
|
145
|
-
// Host-local caches and runtime state
|
|
146
|
-
// so the sharedDirs guard rejects an accidental opt-in.
|
|
154
|
+
// Host-local caches and runtime state (sharedDirs guard also rejects these).
|
|
147
155
|
'cache',
|
|
148
156
|
'backups',
|
|
149
157
|
'paste-cache',
|