@ouro.bot/cli 0.1.0-alpha.286 → 0.1.0-alpha.288
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.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.288",
|
|
6
|
+
"changes": [
|
|
7
|
+
"feat(bundle): full `.gitignore` template + first-push PII review workflow + confirmation-token gate on `bundle_push`. Completes the agent-manages-its-own-bundle chain (PRs 5 + 6 + 7). (1) New `src/repertoire/bundle-templates.ts` exports `BUNDLE_GITIGNORE_TEMPLATE` — a curated gitignore that handles functional cases only (runtime state, credentials, editor/OS noise, build artifacts) and explicitly does NOT block PII. The design philosophy is baked into the file's top-comment: bundles are inherently full of PII (friends/, diary/, journal/, psyche/, arc/, facts/, family/, travel/) and blocking those via gitignore would defeat the bundle's purpose. PII is handled at first-push time by a separate safety layer instead. Also exports `PII_BUNDLE_DIRECTORIES` as the canonical list of PII-bearing top-level dirs. (2) `bundle_init_git` now writes the full template instead of the minimal `state/`-only placeholder from PR 6. (3) New `bundle_first_push_review` tool enumerates existing PII directories with per-directory file counts (honoring `.gitignore` via `git ls-files --others --exclude-standard`), probes the remote URL for GitHub public/private visibility via an unauthenticated `fetch` to `https://api.github.com/repos/{owner}/{repo}` with a 5-second timeout, and generates a first-person warning text — `warningLevel: 'public_github' | 'private_github' | 'generic'`. The tool issues a `confirmationToken` (crypto.randomUUID) stored in a module-level Map with a 15-minute TTL and returns it in the payload. (4) `bundle_push` updated to accept an optional `confirmation_token` parameter: on first-push attempts (detected via `git ls-remote --heads <remote> <branch>` empty — or, conservatively, when that probe fails to reach the remote), the handler requires a valid token that was issued for the SAME bundleRoot and has not expired. Missing, invalid, wrong-bundle, or expired token returns `kind: 'confirmation_required'`. On successful validation, the token is consumed (one-shot). This is the Directive D PII-review gate: the agent cannot push a bundle to the internet without the human explicitly acknowledging the PII payload first. 17 new tests cover the template, PII counting with empty and populated directories, GitHub public/private/404/network-error/malformed-response paths, URL parsing for gitlab/self-hosted/github, token storage + TTL expiry, and every token-gating refusal path in bundle_push. Bundle-templates.ts is added to the file-completeness exempt list since it's a pure constants module (design: `tools-bundle.ts` owns the observability for bundle operations)."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.287",
|
|
12
|
+
"changes": [
|
|
13
|
+
"feat(nerves): add two-layer log redaction to the NDJSON sink. Structured key-based redaction strips sensitive fields (passwords, tokens, API keys, auth headers) from event meta objects before serialization. Regex fallback catches secrets in serialized strings that bypass structured checks (Anthropic keys, OpenAI keys, Bearer tokens, URL token params). Redacted values are replaced with `[REDACTED:key_name]` markers that preserve debugging context without exposing secrets. Redaction happens at the sink level only — in-memory events retain full data for runtime use. `OURO_LOG_VERBOSE=1` env var disables redaction for active debugging sessions. New `src/nerves/redact.ts` module with 38 tests across 3 test files including a 10-case golden corpus covering nested objects, mixed safe/secret fields, and realistic key patterns."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
4
16
|
{
|
|
5
17
|
"version": "0.1.0-alpha.286",
|
|
6
18
|
"changes": [
|
|
@@ -69,6 +69,12 @@ const DISPATCH_EXEMPT_PATTERNS = [
|
|
|
69
69
|
"mind/embedding-provider",
|
|
70
70
|
// Commerce utility module: error classes and pure helpers (no independent side effects).
|
|
71
71
|
"repertoire/commerce-errors",
|
|
72
|
+
// Log redaction: pure utility consumed by the NDJSON sink (no independent side effects).
|
|
73
|
+
"nerves/redact",
|
|
74
|
+
// Bundle templates: pure constants (gitignore template string, PII
|
|
75
|
+
// directory list). No runtime behavior — consumed by tools-bundle.ts
|
|
76
|
+
// which owns the observability for bundle operations.
|
|
77
|
+
"repertoire/bundle-templates",
|
|
72
78
|
];
|
|
73
79
|
function isDispatchExempt(filePath) {
|
|
74
80
|
return DISPATCH_EXEMPT_PATTERNS.some((pattern) => filePath.includes(pattern));
|
package/dist/nerves/index.js
CHANGED
|
@@ -54,6 +54,7 @@ const zlib = __importStar(require("zlib"));
|
|
|
54
54
|
// lazily inside `getRuntimeLogger()`, and we only call `emitNervesEvent` from
|
|
55
55
|
// inside `rotateIfNeeded` — never at module top level.
|
|
56
56
|
const runtime_1 = require("./runtime");
|
|
57
|
+
const redact_1 = require("./redact");
|
|
57
58
|
const LEVEL_PRIORITY = {
|
|
58
59
|
debug: 10,
|
|
59
60
|
info: 20,
|
|
@@ -293,6 +294,7 @@ function createNdjsonFileSink(filePath, optionsOrMaxSize) {
|
|
|
293
294
|
? { maxSizeBytes: optionsOrMaxSize }
|
|
294
295
|
: optionsOrMaxSize ?? {};
|
|
295
296
|
const rotationCheckInterval = options.rotationCheckIntervalBytes ?? DEFAULT_ROTATION_CHECK_INTERVAL_BYTES;
|
|
297
|
+
const verbose = process.env.OURO_LOG_VERBOSE === "1";
|
|
296
298
|
const queue = [];
|
|
297
299
|
let flushing = false;
|
|
298
300
|
let bytesSinceCheck = 0;
|
|
@@ -316,7 +318,9 @@ function createNdjsonFileSink(filePath, optionsOrMaxSize) {
|
|
|
316
318
|
});
|
|
317
319
|
}
|
|
318
320
|
return (entry) => {
|
|
319
|
-
const line =
|
|
321
|
+
const line = verbose
|
|
322
|
+
? `${JSON.stringify(entry)}\n`
|
|
323
|
+
: `${(0, redact_1.redactString)(JSON.stringify((0, redact_1.redactLogEntry)(entry)))}\n`;
|
|
320
324
|
bytesSinceCheck += line.length;
|
|
321
325
|
queue.push(line);
|
|
322
326
|
flush();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SENSITIVE_PATTERNS = exports.SENSITIVE_KEYS = void 0;
|
|
4
|
+
exports.redactMeta = redactMeta;
|
|
5
|
+
exports.redactString = redactString;
|
|
6
|
+
exports.redactLogEntry = redactLogEntry;
|
|
7
|
+
/**
|
|
8
|
+
* Lowercase key names that trigger structured redaction.
|
|
9
|
+
* Matched case-insensitively against meta object keys.
|
|
10
|
+
*/
|
|
11
|
+
exports.SENSITIVE_KEYS = new Set([
|
|
12
|
+
"password",
|
|
13
|
+
"secret",
|
|
14
|
+
"token",
|
|
15
|
+
"accesstoken",
|
|
16
|
+
"refreshtoken",
|
|
17
|
+
"clientsecret",
|
|
18
|
+
"apikey",
|
|
19
|
+
"oauthaccesstoken",
|
|
20
|
+
"authorization",
|
|
21
|
+
"cookie",
|
|
22
|
+
"set-cookie",
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* Regex patterns for string-level fallback redaction.
|
|
26
|
+
* Applied in order; each match is replaced with `[REDACTED:name]`.
|
|
27
|
+
*/
|
|
28
|
+
exports.SENSITIVE_PATTERNS = [
|
|
29
|
+
{ name: "anthropic_key", pattern: /sk-ant-[A-Za-z0-9_-]+/g },
|
|
30
|
+
{ name: "openai_key", pattern: /sk-proj-[A-Za-z0-9_-]+/g },
|
|
31
|
+
{ name: "bearer_token", pattern: /Bearer [A-Za-z0-9\-_.]+/g },
|
|
32
|
+
{ name: "api_key_assignment", pattern: /api[_-]?key[=:]\s*["']?[A-Za-z0-9\-_.]+/g },
|
|
33
|
+
{ name: "url_token", pattern: /[?&](?:token|key|api_key|access_token)=[A-Za-z0-9\-_.%]+/g },
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* Deep-clone meta and replace values at sensitive keys with `[REDACTED:key_name]`.
|
|
37
|
+
* Recurses into nested objects. Case-insensitive key matching.
|
|
38
|
+
*/
|
|
39
|
+
function redactMeta(meta) {
|
|
40
|
+
const result = {};
|
|
41
|
+
for (const key of Object.keys(meta)) {
|
|
42
|
+
const value = meta[key];
|
|
43
|
+
if (exports.SENSITIVE_KEYS.has(key.toLowerCase())) {
|
|
44
|
+
result[key] = `[REDACTED:${key}]`;
|
|
45
|
+
}
|
|
46
|
+
else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
47
|
+
result[key] = redactMeta(value);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
result[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Apply all regex patterns to a string, replacing matches with `[REDACTED:pattern_name]`.
|
|
57
|
+
*/
|
|
58
|
+
function redactString(text) {
|
|
59
|
+
let result = text;
|
|
60
|
+
for (const { name, pattern } of exports.SENSITIVE_PATTERNS) {
|
|
61
|
+
// Reset lastIndex for global regexes reused across calls
|
|
62
|
+
pattern.lastIndex = 0;
|
|
63
|
+
result = result.replace(pattern, `[REDACTED:${name}]`);
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Full redaction pipeline: redact meta (structured keys), then redact message (regex patterns).
|
|
69
|
+
* Returns a new LogEvent -- never mutates the input.
|
|
70
|
+
*/
|
|
71
|
+
function redactLogEntry(entry) {
|
|
72
|
+
const redactedMeta = redactMeta(entry.meta);
|
|
73
|
+
const redactedMessage = redactString(entry.message);
|
|
74
|
+
return {
|
|
75
|
+
...entry,
|
|
76
|
+
meta: redactedMeta,
|
|
77
|
+
message: redactedMessage,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Templates for agent bundle scaffolding.
|
|
4
|
+
*
|
|
5
|
+
* ## .gitignore design philosophy
|
|
6
|
+
*
|
|
7
|
+
* The bundle .gitignore handles FUNCTIONAL "shouldn't track" cases only:
|
|
8
|
+
*
|
|
9
|
+
* - Runtime state (sessions, logs, runtime files) — stale data with no
|
|
10
|
+
* value for review or history.
|
|
11
|
+
* - Credentials — real secrets live in `~/.agentsecrets`, but defense
|
|
12
|
+
* in depth in case anything leaks into the bundle.
|
|
13
|
+
* - Editor / OS noise (.DS_Store, .idea/, etc.).
|
|
14
|
+
* - Build artifacts (rare in bundles, but possible).
|
|
15
|
+
*
|
|
16
|
+
* It DOES NOT handle PII. The bundle is inherently full of PII — `friends/`,
|
|
17
|
+
* `diary/`, `journal/`, `psyche/`, `arc/`, `facts/`, `family/`, `travel/`
|
|
18
|
+
* etc. That's the point of the bundle; blocking those via .gitignore would
|
|
19
|
+
* defeat the purpose.
|
|
20
|
+
*
|
|
21
|
+
* PII is handled at first-push time by `bundle_first_push_review`, which
|
|
22
|
+
* enumerates PII-bearing directories, shows the agent counts, probes the
|
|
23
|
+
* remote URL for GitHub visibility, and hard-pauses until the human
|
|
24
|
+
* confirms. See Directive D in the planning doc.
|
|
25
|
+
*
|
|
26
|
+
* No content-pattern blocks (no `**\/sk-ant-*` or similar). Content-review
|
|
27
|
+
* failures are a different safety layer — credential scanning at commit
|
|
28
|
+
* time would be a follow-up feature.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.PII_BUNDLE_DIRECTORIES = exports.BUNDLE_GITIGNORE_TEMPLATE = void 0;
|
|
32
|
+
exports.BUNDLE_GITIGNORE_TEMPLATE = `# Runtime state — sessions, logs, runtime files, never tracked
|
|
33
|
+
state/
|
|
34
|
+
|
|
35
|
+
# Credentials — never tracked. Real secrets live in ~/.agentsecrets, but
|
|
36
|
+
# defense in depth in case anything leaks into the bundle.
|
|
37
|
+
.env
|
|
38
|
+
.env.*
|
|
39
|
+
secrets/
|
|
40
|
+
**/*.key
|
|
41
|
+
**/*.pem
|
|
42
|
+
**/*.credentials
|
|
43
|
+
**/*.pfx
|
|
44
|
+
|
|
45
|
+
# Editor and OS noise
|
|
46
|
+
.DS_Store
|
|
47
|
+
.idea/
|
|
48
|
+
.vscode/
|
|
49
|
+
*.swp
|
|
50
|
+
*.swo
|
|
51
|
+
|
|
52
|
+
# Build artifacts (rare in bundles, but possible if a workspace lands here)
|
|
53
|
+
node_modules/
|
|
54
|
+
dist/
|
|
55
|
+
`;
|
|
56
|
+
/**
|
|
57
|
+
* PII-sensitive top-level directories. Enumerated here so `bundle_first_push_review`
|
|
58
|
+
* can categorize and count. Adding a new PII bucket to the bundle means adding
|
|
59
|
+
* it here so the first-push warning includes it.
|
|
60
|
+
*/
|
|
61
|
+
exports.PII_BUNDLE_DIRECTORIES = [
|
|
62
|
+
"friends",
|
|
63
|
+
"diary",
|
|
64
|
+
"journal",
|
|
65
|
+
"psyche",
|
|
66
|
+
"arc",
|
|
67
|
+
"facts",
|
|
68
|
+
"family",
|
|
69
|
+
"travel",
|
|
70
|
+
"memory",
|
|
71
|
+
"sessions",
|
|
72
|
+
];
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.bundleToolDefinitions = void 0;
|
|
37
|
+
exports.__getConfirmationTokenStore = __getConfirmationTokenStore;
|
|
37
38
|
/**
|
|
38
39
|
* Agent-callable bundle management tools.
|
|
39
40
|
*
|
|
@@ -71,11 +72,13 @@ exports.bundleToolDefinitions = void 0;
|
|
|
71
72
|
* calls `bundle_list_first_commit` and stages each entry individually.
|
|
72
73
|
*/
|
|
73
74
|
const child_process_1 = require("child_process");
|
|
75
|
+
const crypto = __importStar(require("crypto"));
|
|
74
76
|
const fs = __importStar(require("fs"));
|
|
75
77
|
const path = __importStar(require("path"));
|
|
76
78
|
const runtime_1 = require("../nerves/runtime");
|
|
77
79
|
const identity_1 = require("../heart/identity");
|
|
78
80
|
const bundle_state_1 = require("../heart/bundle-state");
|
|
81
|
+
const bundle_templates_1 = require("./bundle-templates");
|
|
79
82
|
function gitExec(bundleRoot, args, timeoutMs = 10000) {
|
|
80
83
|
try {
|
|
81
84
|
const stdout = (0, child_process_1.execFileSync)("git", args, {
|
|
@@ -252,12 +255,14 @@ const initGitHandler = (args) => {
|
|
|
252
255
|
}
|
|
253
256
|
/* v8 ignore stop */
|
|
254
257
|
}
|
|
255
|
-
// Write
|
|
256
|
-
//
|
|
258
|
+
// Write the full .gitignore template if one doesn't already exist.
|
|
259
|
+
// See BUNDLE_GITIGNORE_TEMPLATE's design philosophy: functional blocks
|
|
260
|
+
// only (credentials, state, noise, build artifacts); PII is handled
|
|
261
|
+
// separately by bundle_first_push_review at first-push time.
|
|
257
262
|
const gitignorePath = path.join(bundleRoot, ".gitignore");
|
|
258
263
|
let gitignoreWritten = false;
|
|
259
264
|
if (!fs.existsSync(gitignorePath)) {
|
|
260
|
-
fs.writeFileSync(gitignorePath,
|
|
265
|
+
fs.writeFileSync(gitignorePath, bundle_templates_1.BUNDLE_GITIGNORE_TEMPLATE, "utf-8");
|
|
261
266
|
gitignoreWritten = true;
|
|
262
267
|
}
|
|
263
268
|
const result = { ok: true, alreadyInit, gitignoreWritten };
|
|
@@ -489,6 +494,155 @@ const doFirstCommitHandler = (args) => {
|
|
|
489
494
|
const commitSha = rev.stdout.trim();
|
|
490
495
|
return finish(true, { commitSha, fileCount: filesToStage.length, message });
|
|
491
496
|
};
|
|
497
|
+
const CONFIRMATION_TOKEN_TTL_MS = 15 * 60 * 1000;
|
|
498
|
+
const _confirmationTokens = new Map();
|
|
499
|
+
function pruneExpiredTokens(now) {
|
|
500
|
+
for (const [token, entry] of _confirmationTokens) {
|
|
501
|
+
if (now - entry.createdAt > CONFIRMATION_TOKEN_TTL_MS) {
|
|
502
|
+
_confirmationTokens.delete(token);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/** Test hook: lets unit tests inspect + clear the token store. */
|
|
507
|
+
function __getConfirmationTokenStore() {
|
|
508
|
+
return _confirmationTokens;
|
|
509
|
+
}
|
|
510
|
+
function countFilesInDir(bundleRoot, relDir) {
|
|
511
|
+
// Use `git ls-files --others --exclude-standard -- <dir>` to honor .gitignore.
|
|
512
|
+
const result = gitExec(bundleRoot, ["ls-files", "--others", "--exclude-standard", "--", relDir]);
|
|
513
|
+
/* v8 ignore next -- git ls-files failure requires a corrupt repo @preserve */
|
|
514
|
+
if (result.code !== 0)
|
|
515
|
+
return 0;
|
|
516
|
+
return result.stdout.split("\n").filter((l) => l.trim().length > 0).length;
|
|
517
|
+
}
|
|
518
|
+
function parseGitHubUrl(url) {
|
|
519
|
+
// https://github.com/owner/repo(.git)
|
|
520
|
+
const httpsMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/);
|
|
521
|
+
if (httpsMatch)
|
|
522
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
523
|
+
// git@github.com:owner/repo(.git)
|
|
524
|
+
const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/);
|
|
525
|
+
if (sshMatch)
|
|
526
|
+
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
async function checkGitHubVisibility(parsed, fetchFn) {
|
|
530
|
+
const controller = new AbortController();
|
|
531
|
+
/* v8 ignore next -- 5-second timeout abort only fires if fetch takes longer than the test runner allows; the abort path is defensive @preserve */
|
|
532
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
533
|
+
try {
|
|
534
|
+
const res = await fetchFn(`https://api.github.com/repos/${parsed.owner}/${parsed.repo}`, {
|
|
535
|
+
signal: controller.signal,
|
|
536
|
+
headers: { Accept: "application/vnd.github+json" },
|
|
537
|
+
});
|
|
538
|
+
if (!res.ok)
|
|
539
|
+
return "unknown";
|
|
540
|
+
const data = (await res.json());
|
|
541
|
+
if (data.private === true)
|
|
542
|
+
return "private";
|
|
543
|
+
if (data.private === false)
|
|
544
|
+
return "public";
|
|
545
|
+
/* v8 ignore next -- malformed GitHub API response (private field neither true nor false) is not practical to provoke @preserve */
|
|
546
|
+
return "unknown";
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return "unknown";
|
|
550
|
+
}
|
|
551
|
+
finally {
|
|
552
|
+
clearTimeout(timer);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function makeFirstPushReviewHandler(deps = {}) {
|
|
556
|
+
return async () => {
|
|
557
|
+
const bundleRoot = (0, identity_1.getAgentRoot)();
|
|
558
|
+
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
559
|
+
const now = deps.now ?? Date.now;
|
|
560
|
+
(0, runtime_1.emitNervesEvent)({
|
|
561
|
+
component: "repertoire",
|
|
562
|
+
event: "repertoire.bundle_first_push_review_start",
|
|
563
|
+
message: "reviewing bundle for first push",
|
|
564
|
+
meta: { bundleRoot },
|
|
565
|
+
});
|
|
566
|
+
const finish = (ok, payload) => {
|
|
567
|
+
(0, runtime_1.emitNervesEvent)({
|
|
568
|
+
component: "repertoire",
|
|
569
|
+
event: "repertoire.bundle_first_push_review_end",
|
|
570
|
+
message: "bundle_first_push_review finished",
|
|
571
|
+
meta: { bundleRoot, ok, ...payload },
|
|
572
|
+
});
|
|
573
|
+
return json({ ok, ...payload });
|
|
574
|
+
};
|
|
575
|
+
if (!isGitRepo(bundleRoot)) {
|
|
576
|
+
return finish(false, { error: "bundle is not a git repo — run bundle_init_git first" });
|
|
577
|
+
}
|
|
578
|
+
const remotes = listRemotes(bundleRoot);
|
|
579
|
+
if (remotes.length === 0) {
|
|
580
|
+
return finish(false, { error: "no remote configured — run bundle_add_remote first" });
|
|
581
|
+
}
|
|
582
|
+
const remoteName = remotes[0];
|
|
583
|
+
const remoteUrl = getRemoteUrl(bundleRoot, remoteName);
|
|
584
|
+
/* v8 ignore next 3 -- listRemotes returned a name but get-url failed; only possible under a git race @preserve */
|
|
585
|
+
if (!remoteUrl) {
|
|
586
|
+
return finish(false, { error: "could not resolve remote url" });
|
|
587
|
+
}
|
|
588
|
+
// Enumerate PII directory counts
|
|
589
|
+
const piiCounts = {};
|
|
590
|
+
let totalPiiRecords = 0;
|
|
591
|
+
for (const dir of bundle_templates_1.PII_BUNDLE_DIRECTORIES) {
|
|
592
|
+
const dirPath = path.join(bundleRoot, dir);
|
|
593
|
+
if (!fs.existsSync(dirPath))
|
|
594
|
+
continue;
|
|
595
|
+
const count = countFilesInDir(bundleRoot, dir);
|
|
596
|
+
if (count > 0) {
|
|
597
|
+
piiCounts[dir] = count;
|
|
598
|
+
totalPiiRecords += count;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// GitHub visibility probe
|
|
602
|
+
const parsedGitHub = parseGitHubUrl(remoteUrl);
|
|
603
|
+
let warningLevel = "generic";
|
|
604
|
+
if (parsedGitHub) {
|
|
605
|
+
const visibility = await checkGitHubVisibility(parsedGitHub, fetchFn);
|
|
606
|
+
if (visibility === "public")
|
|
607
|
+
warningLevel = "public_github";
|
|
608
|
+
else if (visibility === "private")
|
|
609
|
+
warningLevel = "private_github";
|
|
610
|
+
}
|
|
611
|
+
// Build first-person warning text
|
|
612
|
+
const piiSummary = Object.entries(piiCounts)
|
|
613
|
+
.map(([dir, count]) => `${count} ${dir} record${count === 1 ? "" : "s"}`)
|
|
614
|
+
.join(", ");
|
|
615
|
+
const piiClause = piiSummary.length > 0
|
|
616
|
+
? `my bundle contains personal data: ${piiSummary} (${totalPiiRecords} records total)`
|
|
617
|
+
: "my bundle has no PII directories populated yet";
|
|
618
|
+
let visibilityClause;
|
|
619
|
+
if (warningLevel === "public_github") {
|
|
620
|
+
visibilityClause = `⚠️ ${remoteUrl} is a PUBLIC GitHub repo — anything i push will be visible to anyone. are you SURE you want to push PII to a public repo?`;
|
|
621
|
+
}
|
|
622
|
+
else if (warningLevel === "private_github") {
|
|
623
|
+
visibilityClause = `${remoteUrl} is a PRIVATE GitHub repo. still, confirm you want to push this data there.`;
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
visibilityClause = `${remoteUrl} — i can't verify this remote's visibility. confirm the repo is private before i push PII.`;
|
|
627
|
+
}
|
|
628
|
+
const warningText = `${piiClause}. ${visibilityClause}`;
|
|
629
|
+
// Issue and store the confirmation token
|
|
630
|
+
const token = crypto.randomUUID();
|
|
631
|
+
const currentTime = now();
|
|
632
|
+
pruneExpiredTokens(currentTime);
|
|
633
|
+
_confirmationTokens.set(token, { bundleRoot, createdAt: currentTime });
|
|
634
|
+
return finish(true, {
|
|
635
|
+
warningLevel,
|
|
636
|
+
remoteUrl,
|
|
637
|
+
piiCounts,
|
|
638
|
+
totalPiiRecords,
|
|
639
|
+
warningText,
|
|
640
|
+
confirmRequired: true,
|
|
641
|
+
confirmationToken: token,
|
|
642
|
+
});
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
const firstPushReviewHandler = makeFirstPushReviewHandler();
|
|
492
646
|
// ─── tool: bundle_push ─────────────────────────────────────────────────
|
|
493
647
|
/* v8 ignore start -- push error classification branches require mocking git stderr from each failure mode; covered by a single network-failure integration test in the suite @preserve */
|
|
494
648
|
function classifyPushError(stderr) {
|
|
@@ -502,43 +656,130 @@ function classifyPushError(stderr) {
|
|
|
502
656
|
return "unknown";
|
|
503
657
|
}
|
|
504
658
|
/* v8 ignore stop */
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
659
|
+
/**
|
|
660
|
+
* Detect whether this is a first push to the remote.
|
|
661
|
+
*
|
|
662
|
+
* Returns true if the remote branch does not yet exist (bundle has local
|
|
663
|
+
* commits but has never been pushed). Also returns true — conservatively —
|
|
664
|
+
* when git fails to probe the remote (network unreachable, git error).
|
|
665
|
+
* The token requirement is a security gate; if we can't verify the remote
|
|
666
|
+
* state, we assume the worst and force the agent to get confirmation. An
|
|
667
|
+
* unreachable remote should NOT be a bypass vector.
|
|
668
|
+
*/
|
|
669
|
+
/* v8 ignore start -- isFirstPushToRemote has two branches that only differ when the remote is reachable; in the unit test environment all remotes are unreachable so we always hit the "return true on network failure" branch. The security contract (never return false when unsure) is covered by the refusal tests in bundle-first-push-review.test.ts @preserve */
|
|
670
|
+
function isFirstPushToRemote(bundleRoot, remote) {
|
|
671
|
+
const branchResult = gitExec(bundleRoot, ["symbolic-ref", "--short", "HEAD"]);
|
|
672
|
+
if (branchResult.code !== 0)
|
|
673
|
+
return true;
|
|
674
|
+
const branch = branchResult.stdout.trim();
|
|
675
|
+
const lsRemote = gitExec(bundleRoot, ["ls-remote", "--heads", remote, branch], 10000);
|
|
676
|
+
if (lsRemote.code !== 0)
|
|
677
|
+
return true;
|
|
678
|
+
return lsRemote.stdout.trim().length === 0;
|
|
679
|
+
}
|
|
680
|
+
/* v8 ignore stop */
|
|
681
|
+
function makePushHandler(deps = {}) {
|
|
682
|
+
return (args) => {
|
|
683
|
+
const bundleRoot = (0, identity_1.getAgentRoot)();
|
|
684
|
+
const remote = typeof args.remote === "string" && args.remote.length > 0 ? args.remote : "origin";
|
|
685
|
+
const confirmationToken = typeof args.confirmation_token === "string"
|
|
686
|
+
? args.confirmation_token
|
|
687
|
+
: undefined;
|
|
688
|
+
const now = deps.now ?? Date.now;
|
|
515
689
|
(0, runtime_1.emitNervesEvent)({
|
|
516
690
|
component: "repertoire",
|
|
517
|
-
event: "repertoire.
|
|
518
|
-
message: "
|
|
519
|
-
meta: { bundleRoot,
|
|
691
|
+
event: "repertoire.bundle_push_start",
|
|
692
|
+
message: "pushing bundle to remote",
|
|
693
|
+
meta: { bundleRoot, remote },
|
|
520
694
|
});
|
|
521
|
-
|
|
695
|
+
const finish = (ok, payload) => {
|
|
696
|
+
(0, runtime_1.emitNervesEvent)({
|
|
697
|
+
component: "repertoire",
|
|
698
|
+
event: "repertoire.bundle_push_end",
|
|
699
|
+
message: "bundle_push finished",
|
|
700
|
+
meta: { bundleRoot, ok, ...payload },
|
|
701
|
+
});
|
|
702
|
+
return json({ ok, ...payload });
|
|
703
|
+
};
|
|
704
|
+
if (!isGitRepo(bundleRoot)) {
|
|
705
|
+
return finish(false, { error: "bundle is not a git repo" });
|
|
706
|
+
}
|
|
707
|
+
const remotes = listRemotes(bundleRoot);
|
|
708
|
+
if (!remotes.includes(remote)) {
|
|
709
|
+
return finish(false, { error: `remote "${remote}" not configured — run bundle_add_remote first` });
|
|
710
|
+
}
|
|
711
|
+
if (!hasHead(bundleRoot)) {
|
|
712
|
+
return finish(false, { error: "bundle has no commits — run bundle_do_first_commit first" });
|
|
713
|
+
}
|
|
714
|
+
// Directive D: first push to a remote requires the agent to have
|
|
715
|
+
// previously called bundle_first_push_review and obtained a
|
|
716
|
+
// confirmation token. This forces a PII-review gate between
|
|
717
|
+
// "bundle built locally" and "bundle contents on the internet".
|
|
718
|
+
const isFirstPush = isFirstPushToRemote(bundleRoot, remote);
|
|
719
|
+
/* v8 ignore next -- the !isFirstPush branch (subsequent push path) requires a reachable remote to test end-to-end; the path itself falls through to the git push below which is covered by the network-failure test @preserve */
|
|
720
|
+
if (isFirstPush) {
|
|
721
|
+
if (!confirmationToken) {
|
|
722
|
+
(0, runtime_1.emitNervesEvent)({
|
|
723
|
+
level: "warn",
|
|
724
|
+
component: "repertoire",
|
|
725
|
+
event: "repertoire.bundle_push_refused",
|
|
726
|
+
message: "bundle_push refused: first push requires confirmation token",
|
|
727
|
+
meta: { bundleRoot },
|
|
728
|
+
});
|
|
729
|
+
return finish(false, {
|
|
730
|
+
error: "refused: first push requires a confirmation token — call bundle_first_push_review, show the warning to the user, and pass the returned confirmationToken to bundle_push",
|
|
731
|
+
kind: "confirmation_required",
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
const currentTime = now();
|
|
735
|
+
pruneExpiredTokens(currentTime);
|
|
736
|
+
const entry = _confirmationTokens.get(confirmationToken);
|
|
737
|
+
if (!entry) {
|
|
738
|
+
(0, runtime_1.emitNervesEvent)({
|
|
739
|
+
level: "warn",
|
|
740
|
+
component: "repertoire",
|
|
741
|
+
event: "repertoire.bundle_push_refused",
|
|
742
|
+
message: "bundle_push refused: confirmation token invalid or expired",
|
|
743
|
+
meta: { bundleRoot },
|
|
744
|
+
});
|
|
745
|
+
return finish(false, {
|
|
746
|
+
error: "refused: confirmation token invalid or expired — call bundle_first_push_review again",
|
|
747
|
+
kind: "confirmation_required",
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
if (entry.bundleRoot !== bundleRoot) {
|
|
751
|
+
(0, runtime_1.emitNervesEvent)({
|
|
752
|
+
level: "warn",
|
|
753
|
+
component: "repertoire",
|
|
754
|
+
event: "repertoire.bundle_push_refused",
|
|
755
|
+
message: "bundle_push refused: confirmation token bound to a different bundle",
|
|
756
|
+
meta: { bundleRoot, tokenBundleRoot: entry.bundleRoot },
|
|
757
|
+
});
|
|
758
|
+
return finish(false, {
|
|
759
|
+
error: "refused: confirmation token was issued for a different bundle",
|
|
760
|
+
kind: "confirmation_required",
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
(0, runtime_1.emitNervesEvent)({
|
|
764
|
+
component: "repertoire",
|
|
765
|
+
event: "repertoire.bundle_push_first_push_confirmed",
|
|
766
|
+
message: "first push confirmed by valid token",
|
|
767
|
+
meta: { bundleRoot },
|
|
768
|
+
});
|
|
769
|
+
// One-shot: consume the token on successful validation.
|
|
770
|
+
_confirmationTokens.delete(confirmationToken);
|
|
771
|
+
}
|
|
772
|
+
const push = gitExec(bundleRoot, ["push", remote, "HEAD"], 30000);
|
|
773
|
+
/* v8 ignore start -- push success branch requires a reachable remote, not practical to cover in unit tests; failure branch covered by network-failure test @preserve */
|
|
774
|
+
if (push.code === 0) {
|
|
775
|
+
return finish(true, { remote, firstPush: isFirstPush });
|
|
776
|
+
}
|
|
777
|
+
/* v8 ignore stop */
|
|
778
|
+
const kind = classifyPushError(push.stderr);
|
|
779
|
+
return finish(false, { error: push.stderr.trim(), kind });
|
|
522
780
|
};
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
const remotes = listRemotes(bundleRoot);
|
|
527
|
-
if (!remotes.includes(remote)) {
|
|
528
|
-
return finish(false, { error: `remote "${remote}" not configured — run bundle_add_remote first` });
|
|
529
|
-
}
|
|
530
|
-
if (!hasHead(bundleRoot)) {
|
|
531
|
-
return finish(false, { error: "bundle has no commits — run bundle_do_first_commit first" });
|
|
532
|
-
}
|
|
533
|
-
const push = gitExec(bundleRoot, ["push", remote, "HEAD"], 30000);
|
|
534
|
-
/* v8 ignore start -- push success branch requires a reachable remote, not practical to cover in unit tests; failure branch covered by network-failure test @preserve */
|
|
535
|
-
if (push.code === 0) {
|
|
536
|
-
return finish(true, { remote });
|
|
537
|
-
}
|
|
538
|
-
/* v8 ignore stop */
|
|
539
|
-
const kind = classifyPushError(push.stderr);
|
|
540
|
-
return finish(false, { error: push.stderr.trim(), kind });
|
|
541
|
-
};
|
|
781
|
+
}
|
|
782
|
+
const pushHandler = makePushHandler();
|
|
542
783
|
// ─── tool: bundle_pull_rebase ──────────────────────────────────────────
|
|
543
784
|
const pullRebaseHandler = (args) => {
|
|
544
785
|
const bundleRoot = (0, identity_1.getAgentRoot)();
|
|
@@ -689,16 +930,28 @@ exports.bundleToolDefinitions = [
|
|
|
689
930
|
},
|
|
690
931
|
handler: doFirstCommitHandler,
|
|
691
932
|
},
|
|
933
|
+
{
|
|
934
|
+
tool: {
|
|
935
|
+
type: "function",
|
|
936
|
+
function: {
|
|
937
|
+
name: "bundle_first_push_review",
|
|
938
|
+
description: "Review my bundle for PII exposure before the first push to a new remote. Enumerates PII-bearing directories (friends, diary, journal, etc.) with per-directory counts, probes the remote URL for GitHub public/private visibility, and returns a first-person warning text I must show the human plus a confirmationToken I must pass to bundle_push on first push. Required before the first push to any new remote.",
|
|
939
|
+
parameters: { type: "object", properties: {} },
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
handler: firstPushReviewHandler,
|
|
943
|
+
},
|
|
692
944
|
{
|
|
693
945
|
tool: {
|
|
694
946
|
type: "function",
|
|
695
947
|
function: {
|
|
696
948
|
name: "bundle_push",
|
|
697
|
-
description: "Push my bundle's HEAD to the configured remote. Returns a structured error with kind: 'rejected' | 'network' | 'auth' | 'unknown' on failure. Does NOT auto-rebase on rejection — use bundle_pull_rebase explicitly when needed.",
|
|
949
|
+
description: "Push my bundle's HEAD to the configured remote. On first push to a new remote, requires a confirmation_token from bundle_first_push_review (Directive D: human must acknowledge PII exposure before the bundle goes over the wire). Subsequent pushes ignore the token. Returns a structured error with kind: 'rejected' | 'network' | 'auth' | 'unknown' | 'confirmation_required' on failure. Does NOT auto-rebase on rejection — use bundle_pull_rebase explicitly when needed.",
|
|
698
950
|
parameters: {
|
|
699
951
|
type: "object",
|
|
700
952
|
properties: {
|
|
701
953
|
remote: { type: "string", description: "Remote name. Defaults to 'origin'." },
|
|
954
|
+
confirmation_token: { type: "string", description: "Opaque token from bundle_first_push_review. Required on the first push to a new remote; ignored on subsequent pushes." },
|
|
702
955
|
},
|
|
703
956
|
},
|
|
704
957
|
},
|