ai-collab-open-system 0.1.0
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/.aict/START_HERE.md +127 -0
- package/.aict/WORKSPACE_MANIFEST.json +91 -0
- package/.aict/acceptance/EXAMPLE.synthetic.md +49 -0
- package/.aict/acceptance/FAILURE_MODES.md +40 -0
- package/.aict/acceptance/PROMPT.md +47 -0
- package/.aict/acceptance/README.md +44 -0
- package/.aict/acceptance/TEMPLATE.md +57 -0
- package/.aict/adapters/SHARED_CORE_CONTRACT.md +106 -0
- package/.aict/adapters/claude-code/ADAPTER.md +28 -0
- package/.aict/adapters/cline/ADAPTER.md +28 -0
- package/.aict/adapters/codex/ADAPTER.md +28 -0
- package/.aict/adapters/copilot/ADAPTER.md +28 -0
- package/.aict/adapters/cursor/ADAPTER.md +28 -0
- package/.aict/adapters/windsurf/ADAPTER.md +28 -0
- package/.aict/context/EXAMPLE.synthetic.md +53 -0
- package/.aict/context/FAILURE_MODES.md +40 -0
- package/.aict/context/PROMPT.md +47 -0
- package/.aict/context/README.md +44 -0
- package/.aict/context/TEMPLATE.md +63 -0
- package/.aict/cookbook/README.md +8 -0
- package/.aict/cookbook/bridge-to-a-second-family.md +103 -0
- package/.aict/cookbook/connect-a-tool.md +67 -0
- package/.aict/cookbook/review-a-half-product.md +79 -0
- package/.aict/cookbook/run-a-first-loop.md +81 -0
- package/.aict/examples/README.md +21 -0
- package/.aict/examples/ai-coding-long-task/CASE.md +161 -0
- package/.aict/examples/ai-coding-long-task/artifacts/acceptance-card.md +36 -0
- package/.aict/examples/ai-coding-long-task/artifacts/context-package.md +30 -0
- package/.aict/examples/ai-coding-long-task/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/ai-coding-long-task/artifacts/first-ai-output.md +109 -0
- package/.aict/examples/ai-coding-long-task/artifacts/guard-review.md +40 -0
- package/.aict/examples/ai-coding-long-task/artifacts/handoff-note.md +28 -0
- package/.aict/examples/ai-coding-long-task/artifacts/harvest-seed.md +28 -0
- package/.aict/examples/ai-coding-long-task/artifacts/revised-output.md +62 -0
- package/.aict/examples/content-production-harvest/CASE.md +87 -0
- package/.aict/examples/content-production-harvest/artifacts/acceptance-card.md +28 -0
- package/.aict/examples/content-production-harvest/artifacts/context-package.md +28 -0
- package/.aict/examples/content-production-harvest/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/content-production-harvest/artifacts/guard-review.md +28 -0
- package/.aict/examples/content-production-harvest/artifacts/handoff-note.md +28 -0
- package/.aict/examples/content-production-harvest/artifacts/harvest-seed.md +28 -0
- package/.aict/examples/multi-tool-collaboration/CASE.md +87 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/acceptance-card.md +28 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/context-package.md +28 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/guard-review.md +28 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/handoff-note.md +28 -0
- package/.aict/examples/multi-tool-collaboration/artifacts/harvest-seed.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/CASE.md +87 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/acceptance-card.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/context-package.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/guard-review.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/handoff-note.md +28 -0
- package/.aict/examples/personal-judgment-growth-assistant/artifacts/harvest-seed.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/CASE.md +87 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/acceptance-card.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/context-package.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/execution-prompt.md +30 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/guard-review.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/handoff-note.md +28 -0
- package/.aict/examples/research-knowledge-synthesis/artifacts/harvest-seed.md +28 -0
- package/.aict/guard/EXAMPLE.synthetic.md +51 -0
- package/.aict/guard/FAILURE_MODES.md +40 -0
- package/.aict/guard/PROMPT.md +47 -0
- package/.aict/guard/README.md +44 -0
- package/.aict/guard/TEMPLATE.md +60 -0
- package/.aict/handoff/EXAMPLE.synthetic.md +51 -0
- package/.aict/handoff/FAILURE_MODES.md +40 -0
- package/.aict/handoff/PROMPT.md +47 -0
- package/.aict/handoff/README.md +44 -0
- package/.aict/handoff/TEMPLATE.md +60 -0
- package/.aict/harvest/EXAMPLE.synthetic.md +51 -0
- package/.aict/harvest/FAILURE_MODES.md +40 -0
- package/.aict/harvest/PROMPT.md +47 -0
- package/.aict/harvest/README.md +44 -0
- package/.aict/harvest/TEMPLATE.md +60 -0
- package/.aict/mechanisms/README.md +34 -0
- package/.aict/mechanisms/anti-drift-partner/EXAMPLE.synthetic.md +46 -0
- package/.aict/mechanisms/anti-drift-partner/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/anti-drift-partner/PROMPT.md +75 -0
- package/.aict/mechanisms/anti-drift-partner/README.md +82 -0
- package/.aict/mechanisms/anti-drift-partner/TEMPLATE.md +74 -0
- package/.aict/mechanisms/blind-spot-scan/EXAMPLE.synthetic.md +39 -0
- package/.aict/mechanisms/blind-spot-scan/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/blind-spot-scan/PROMPT.md +72 -0
- package/.aict/mechanisms/blind-spot-scan/README.md +79 -0
- package/.aict/mechanisms/blind-spot-scan/TEMPLATE.md +70 -0
- package/.aict/mechanisms/collaboration-coach/EXAMPLE.synthetic.md +40 -0
- package/.aict/mechanisms/collaboration-coach/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/collaboration-coach/PROMPT.md +72 -0
- package/.aict/mechanisms/collaboration-coach/README.md +79 -0
- package/.aict/mechanisms/collaboration-coach/TEMPLATE.md +61 -0
- package/.aict/mechanisms/do-not-handle-yet/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/do-not-handle-yet/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/do-not-handle-yet/PROMPT.md +41 -0
- package/.aict/mechanisms/do-not-handle-yet/README.md +30 -0
- package/.aict/mechanisms/do-not-handle-yet/TEMPLATE.md +38 -0
- package/.aict/mechanisms/dual-guard/EXAMPLE.synthetic.md +54 -0
- package/.aict/mechanisms/dual-guard/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/dual-guard/PROMPT.md +76 -0
- package/.aict/mechanisms/dual-guard/README.md +81 -0
- package/.aict/mechanisms/dual-guard/TEMPLATE.md +73 -0
- package/.aict/mechanisms/feedback-absorption-ledger/EXAMPLE.synthetic.md +49 -0
- package/.aict/mechanisms/feedback-absorption-ledger/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/feedback-absorption-ledger/PROMPT.md +74 -0
- package/.aict/mechanisms/feedback-absorption-ledger/README.md +81 -0
- package/.aict/mechanisms/feedback-absorption-ledger/TEMPLATE.md +69 -0
- package/.aict/mechanisms/half-product-review/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/half-product-review/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/half-product-review/PROMPT.md +41 -0
- package/.aict/mechanisms/half-product-review/README.md +30 -0
- package/.aict/mechanisms/half-product-review/TEMPLATE.md +38 -0
- package/.aict/mechanisms/handoff-abc/EXAMPLE.synthetic.md +47 -0
- package/.aict/mechanisms/handoff-abc/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/handoff-abc/PROMPT.md +75 -0
- package/.aict/mechanisms/handoff-abc/README.md +82 -0
- package/.aict/mechanisms/handoff-abc/TEMPLATE.md +60 -0
- package/.aict/mechanisms/harvest-and-erc/EXAMPLE.synthetic.md +43 -0
- package/.aict/mechanisms/harvest-and-erc/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/harvest-and-erc/PROMPT.md +74 -0
- package/.aict/mechanisms/harvest-and-erc/README.md +81 -0
- package/.aict/mechanisms/harvest-and-erc/TEMPLATE.md +60 -0
- package/.aict/mechanisms/honest-calibration/EXAMPLE.synthetic.md +43 -0
- package/.aict/mechanisms/honest-calibration/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/honest-calibration/PROMPT.md +74 -0
- package/.aict/mechanisms/honest-calibration/README.md +81 -0
- package/.aict/mechanisms/honest-calibration/TEMPLATE.md +66 -0
- package/.aict/mechanisms/one-click-dispatch/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/one-click-dispatch/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/one-click-dispatch/PROMPT.md +41 -0
- package/.aict/mechanisms/one-click-dispatch/README.md +30 -0
- package/.aict/mechanisms/one-click-dispatch/TEMPLATE.md +38 -0
- package/.aict/mechanisms/plain-language-first-screen/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/plain-language-first-screen/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/plain-language-first-screen/PROMPT.md +41 -0
- package/.aict/mechanisms/plain-language-first-screen/README.md +30 -0
- package/.aict/mechanisms/plain-language-first-screen/TEMPLATE.md +38 -0
- package/.aict/mechanisms/root-cause-brake/EXAMPLE.synthetic.md +55 -0
- package/.aict/mechanisms/root-cause-brake/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/root-cause-brake/PROMPT.md +73 -0
- package/.aict/mechanisms/root-cause-brake/README.md +79 -0
- package/.aict/mechanisms/root-cause-brake/TEMPLATE.md +74 -0
- package/.aict/mechanisms/scout-review-controller/EXAMPLE.synthetic.md +15 -0
- package/.aict/mechanisms/scout-review-controller/FAILURE_MODES.md +16 -0
- package/.aict/mechanisms/scout-review-controller/PROMPT.md +41 -0
- package/.aict/mechanisms/scout-review-controller/README.md +30 -0
- package/.aict/mechanisms/scout-review-controller/TEMPLATE.md +38 -0
- package/.aict/mechanisms/single-tool-guard/EXAMPLE.synthetic.md +54 -0
- package/.aict/mechanisms/single-tool-guard/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/single-tool-guard/PROMPT.md +76 -0
- package/.aict/mechanisms/single-tool-guard/README.md +83 -0
- package/.aict/mechanisms/single-tool-guard/TEMPLATE.md +75 -0
- package/.aict/mechanisms/task-splitting/EXAMPLE.synthetic.md +53 -0
- package/.aict/mechanisms/task-splitting/FAILURE_MODES.md +25 -0
- package/.aict/mechanisms/task-splitting/PROMPT.md +72 -0
- package/.aict/mechanisms/task-splitting/README.md +79 -0
- package/.aict/mechanisms/task-splitting/TEMPLATE.md +76 -0
- package/.aict/modes/README.md +11 -0
- package/.aict/modes/execute.md +31 -0
- package/.aict/modes/handoff.md +29 -0
- package/.aict/modes/harvest.md +30 -0
- package/.aict/modes/review.md +28 -0
- package/.aict/modes/shape.md +34 -0
- package/.aict/privacy/COMMERCIAL_BOUNDARY.md +34 -0
- package/.aict/privacy/PRIVACY.md +36 -0
- package/.aict/privacy/REDACTION_CHECKLIST.md +12 -0
- package/.aict/profile/CANDIDATES.md +44 -0
- package/.aict/profile/EXAMPLE.synthetic.md +49 -0
- package/.aict/profile/FAILURE_MODES.md +40 -0
- package/.aict/profile/PROMPT.md +47 -0
- package/.aict/profile/README.md +44 -0
- package/.aict/profile/TEMPLATE.md +57 -0
- package/.aict/prompts/acceptance-definition.md +109 -0
- package/.aict/prompts/guard-review.md +116 -0
- package/.aict/prompts/handoff-generation.md +110 -0
- package/.aict/prompts/harvest-extraction.md +110 -0
- package/.aict/prompts/mode-switching.md +66 -0
- package/.aict/prompts/profile-creation.md +66 -0
- package/.aict/prompts/profile-refinement.md +66 -0
- package/.aict/prompts/project-context-packaging.md +113 -0
- package/.aict/prompts/red-team-challenge.md +106 -0
- package/.aict/prompts/rule-update-proposal.md +114 -0
- package/.aict/prompts/workflow-reset.md +109 -0
- package/.aict/roles/README.md +18 -0
- package/.aict/roles/executor.md +34 -0
- package/.aict/roles/harvester.md +33 -0
- package/.aict/roles/owner-controller.md +38 -0
- package/.aict/roles/scout.md +33 -0
- package/.aict/roles/supervisor.md +34 -0
- package/.aict/roles/system-guardian.md +34 -0
- package/.aict/skills/acceptance/SKILL.md +43 -0
- package/.aict/skills/context/SKILL.md +44 -0
- package/.aict/skills/evidence-pack/SKILL.md +42 -0
- package/.aict/skills/guard/SKILL.md +46 -0
- package/.aict/skills/handoff/SKILL.md +44 -0
- package/.aict/skills/harvest/SKILL.md +44 -0
- package/.aict/skills/mode-switch/SKILL.md +42 -0
- package/.aict/skills/profile/SKILL.md +42 -0
- package/.aict/skills/red-team/SKILL.md +42 -0
- package/.aict/skills/single-tool-guard/SKILL.md +42 -0
- package/.aict/state/CURRENT_STATE.md +13 -0
- package/.aict/state/DECISIONS.md +7 -0
- package/.aict/state/TASK_LOG.md +7 -0
- package/.aict/state/evidence.jsonl +2 -0
- package/.aict/state/learning-ledger.jsonl +1 -0
- package/.aict/state/receipts.jsonl +1 -0
- package/.aict/state/runs.jsonl +1 -0
- package/.aict/state/tasks.jsonl +1 -0
- package/.aict/walkthroughs/10-minute-your-task.md +107 -0
- package/.aict/walkthroughs/10-minute.md +43 -0
- package/.aict/walkthroughs/30-minute.md +22 -0
- package/.aict/walkthroughs/60-minute.md +27 -0
- package/.aict/walkthroughs/synthetic-loop-transcript.md +43 -0
- package/CHANGELOG.md +23 -0
- package/CODE_OF_CONDUCT.md +20 -0
- package/CONTRIBUTING.md +30 -0
- package/KNOWN_LIMITATIONS.md +54 -0
- package/LICENSE +199 -0
- package/PRODUCT_CONTRACT.md +446 -0
- package/README.md +245 -0
- package/RELEASE_CHECKLIST.md +78 -0
- package/SECURITY.md +56 -0
- package/START_HERE.md +89 -0
- package/bin/ai-collab.js +2 -0
- package/docs/DOGFOOD.md +85 -0
- package/docs/FEEDBACK.md +61 -0
- package/docs/FIRST_EXPERIENCE_SPEC.md +32 -0
- package/docs/FREE_VS_PAID.md +53 -0
- package/docs/PUBLIC_BOUNDARY.md +36 -0
- package/docs/PUBLIC_MAPPING.md +178 -0
- package/docs/RELEASE_PRIORITY.md +23 -0
- package/docs/WHY_THIS_EXISTS.md +36 -0
- package/docs/open-system/00-start-here.md +60 -0
- package/docs/open-system/01-ai-collaboration-os.md +33 -0
- package/docs/open-system/02-six-layer-architecture.md +45 -0
- package/docs/open-system/03-role-system.md +33 -0
- package/docs/open-system/04-core-mechanisms.md +34 -0
- package/docs/open-system/05-failure-patterns.md +31 -0
- package/docs/open-system/06-how-to-adapt-to-your-workflow.md +31 -0
- package/package.json +69 -0
- package/privacy-manifest.json +78 -0
- package/privacy-scan.local.json.example +18 -0
- package/scripts/lib/forbidden-in-pack.js +55 -0
- package/scripts/pack-check.js +154 -0
- package/scripts/privacy-scan.js +487 -0
- package/scripts/validate-contract.js +160 -0
- package/src/adapters.js +590 -0
- package/src/bootstrap.js +1184 -0
- package/src/catalog.js +2723 -0
- package/src/cli.js +2899 -0
- package/src/dialogue.js +470 -0
- package/src/i18n.js +1034 -0
- package/src/ledger.js +2011 -0
- package/src/render.js +1381 -0
- package/src/sendmodel.js +452 -0
- package/src/validate.js +1307 -0
- package/src/workspace.js +1679 -0
- package/tests/contract.test.js +8514 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { findForbiddenPackFiles } from "./lib/forbidden-in-pack.js";
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const args = {};
|
|
12
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
13
|
+
const flag = argv[index];
|
|
14
|
+
if (flag === "--workspace") {
|
|
15
|
+
args.workspace = argv[index + 1];
|
|
16
|
+
index += 1;
|
|
17
|
+
} else if (flag === "--no-extras") {
|
|
18
|
+
// Skip the side-effecting scan surfaces (adapters-install output + npm pack
|
|
19
|
+
// file list). Lets the scanner run as a pure read-only pass over a target
|
|
20
|
+
// directory in a strict sandbox, instead of crashing on EPERM.
|
|
21
|
+
args.noExtras = true;
|
|
22
|
+
} else if (flag === "--strict") {
|
|
23
|
+
args.strict = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return args;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Maintainable denylist / bilingual allowlist (privacy-manifest.json).
|
|
31
|
+
// The scanner reads the manifest so the forbidden paths/terms and the sanctioned
|
|
32
|
+
// bilingual surface can be extended without editing scanner code. If the manifest
|
|
33
|
+
// is missing or malformed we fall back to safe built-in defaults and warn.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
const DEFAULT_DENYLIST = {
|
|
36
|
+
// Note: absolute home prefixes like the users/home roots are intentionally NOT
|
|
37
|
+
// string literals here — the alwaysForbidden regexes already hard-block real
|
|
38
|
+
// local paths everywhere, and putting them as plain strings would make this
|
|
39
|
+
// scanner file flag itself. The manifest `paths` array is the extension point.
|
|
40
|
+
//
|
|
41
|
+
// Keep this list GENERIC (anyone-applies). `.claude/hooks` is the standard
|
|
42
|
+
// Claude Code hooks directory that every Claude Code user has, so it is a safe
|
|
43
|
+
// generic marker for "don't paste your own private hook contents". A
|
|
44
|
+
// maintainer's OWN private dir names (a personal governance folder, a private
|
|
45
|
+
// knowledge base) are NOT listed here — that would ship the maintainer's
|
|
46
|
+
// private dir name in the public package. Users add their own via the
|
|
47
|
+
// gitignored privacy-scan.local.json (see privacy-scan.local.json.example).
|
|
48
|
+
paths: [".claude/hooks"],
|
|
49
|
+
terms: [],
|
|
50
|
+
chineseTerms: []
|
|
51
|
+
};
|
|
52
|
+
const DEFAULT_BILINGUAL = {
|
|
53
|
+
sectionMarkers: ["中文"],
|
|
54
|
+
lineAllow: [],
|
|
55
|
+
// Whole-file sanctioned bilingual SOURCES (relative posix paths). A file listed
|
|
56
|
+
// here is the product's intentional bilingual surface (e.g. the i18n message
|
|
57
|
+
// catalog), so the Chinese-leak HEURISTIC (the unmarked-run check) does not flag
|
|
58
|
+
// its localized strings. This is the manifest-driven extension point for "this
|
|
59
|
+
// file is bilingual by design", parallel to how the scanner already treats its own
|
|
60
|
+
// policy files. IMPORTANT: it ONLY relaxes the run heuristic — the hard token/key/
|
|
61
|
+
// local-path/email rules AND the chineseTerms denylist still apply, so a real
|
|
62
|
+
// secret or a denylisted private Chinese term in such a file STILL fails the scan.
|
|
63
|
+
sanctionedFiles: [],
|
|
64
|
+
zhKeyAllowed: true,
|
|
65
|
+
contextWindow: 12,
|
|
66
|
+
minRun: 4
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Optional, gitignored local override. A maintainer keeps their OWN private dir
|
|
70
|
+
// names / terms here instead of in the shipped manifest, so the public package
|
|
71
|
+
// never names them. When present, its arrays are appended onto the manifest's.
|
|
72
|
+
function loadLocalOverride() {
|
|
73
|
+
const localPath = path.join(repoRoot, "privacy-scan.local.json");
|
|
74
|
+
if (!existsSync(localPath)) return null;
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(localPath, "utf8"));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.warn(`privacy-scan: could not parse privacy-scan.local.json (${error.message}); ignoring local overrides.`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Merge denylist arrays from manifest + local override on top of the generic
|
|
84
|
+
// built-in defaults. Arrays are concatenated and de-duplicated so a user's local
|
|
85
|
+
// private markers extend (never replace) the shipped generic ones.
|
|
86
|
+
function mergeDenylist(base, ...overlays) {
|
|
87
|
+
const merged = { paths: [...(base.paths ?? [])], terms: [...(base.terms ?? [])], chineseTerms: [...(base.chineseTerms ?? [])] };
|
|
88
|
+
for (const overlay of overlays) {
|
|
89
|
+
if (!overlay) continue;
|
|
90
|
+
for (const key of ["paths", "terms", "chineseTerms"]) {
|
|
91
|
+
if (Array.isArray(overlay[key])) merged[key].push(...overlay[key]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const key of ["paths", "terms", "chineseTerms"]) {
|
|
95
|
+
merged[key] = [...new Set(merged[key].filter((entry) => typeof entry === "string" && entry.length > 0))];
|
|
96
|
+
}
|
|
97
|
+
return merged;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadManifest() {
|
|
101
|
+
const localOverride = loadLocalOverride();
|
|
102
|
+
const manifestPath = path.join(repoRoot, "privacy-manifest.json");
|
|
103
|
+
if (!existsSync(manifestPath)) {
|
|
104
|
+
console.warn("privacy-scan: privacy-manifest.json not found; using built-in denylist defaults.");
|
|
105
|
+
return { denylist: mergeDenylist(DEFAULT_DENYLIST, localOverride), bilingual: DEFAULT_BILINGUAL, publicContacts: [] };
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
109
|
+
return {
|
|
110
|
+
denylist: mergeDenylist(DEFAULT_DENYLIST, manifest.scanDenylist ?? {}, localOverride),
|
|
111
|
+
bilingual: { ...DEFAULT_BILINGUAL, ...(manifest.bilingual ?? {}) },
|
|
112
|
+
publicContacts: Array.isArray(manifest.publicContacts) ? manifest.publicContacts : []
|
|
113
|
+
};
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.warn(`privacy-scan: could not parse privacy-manifest.json (${error.message}); using built-in denylist defaults.`);
|
|
116
|
+
return { denylist: mergeDenylist(DEFAULT_DENYLIST, localOverride), bilingual: DEFAULT_BILINGUAL, publicContacts: [] };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { denylist, bilingual, publicContacts } = loadManifest();
|
|
121
|
+
// EXACT-string allowlist for the email rule below. The scanner blocks EVERY email
|
|
122
|
+
// address EXCEPT one that exactly matches an entry here — a narrow, explainable
|
|
123
|
+
// exemption for the project's PUBLISHED public contact only (see manifest
|
|
124
|
+
// publicContacts), NOT a domain or category allowlist: a different address, even at
|
|
125
|
+
// the same domain, still fails. Lower-cased so the comparison is case-insensitive.
|
|
126
|
+
const publicContactEmails = new Set((publicContacts ?? []).map((entry) => String(entry).toLowerCase()));
|
|
127
|
+
// The set of private directory names to also block from the npm tarball: the
|
|
128
|
+
// scanner's path denylist (generic + manifest + local). Generic literals like
|
|
129
|
+
// `.claude/` already live in the shared pack rules; the extra dirs here cover a
|
|
130
|
+
// user's configured private dirs without baking their names into shipped code.
|
|
131
|
+
const configuredPrivateDirs = denylist.paths ?? [];
|
|
132
|
+
|
|
133
|
+
function escapeRegExp(value) {
|
|
134
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const sectionMarkerRegexes = (bilingual.sectionMarkers ?? []).map((marker) => new RegExp(escapeRegExp(marker)));
|
|
138
|
+
const lineAllowRegexes = (bilingual.lineAllow ?? []).map((pattern) => new RegExp(pattern));
|
|
139
|
+
// Normalize the whole-file sanctioned bilingual sources to a set of posix-relative
|
|
140
|
+
// paths for an exact match in scanFile.
|
|
141
|
+
const sanctionedBilingualFiles = new Set(
|
|
142
|
+
(bilingual.sanctionedFiles ?? []).map((p) => String(p).split(path.sep).join("/"))
|
|
143
|
+
);
|
|
144
|
+
const cjkRun = new RegExp(`[\\u4e00-\\u9fff]{${Math.max(1, bilingual.minRun ?? 4)},}`);
|
|
145
|
+
const cjkAny = /[一-鿿]/;
|
|
146
|
+
|
|
147
|
+
function walk(root, files = []) {
|
|
148
|
+
if (!existsSync(root)) return files;
|
|
149
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
150
|
+
if ([".git", "node_modules", ".DS_Store"].includes(entry.name)) continue;
|
|
151
|
+
const fullPath = path.join(root, entry.name);
|
|
152
|
+
if (entry.isDirectory()) walk(fullPath, files);
|
|
153
|
+
else files.push(fullPath);
|
|
154
|
+
}
|
|
155
|
+
return files;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isPolicyFile(file, root) {
|
|
159
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
160
|
+
const name = path.basename(file).toLowerCase();
|
|
161
|
+
if (relative === "scripts/privacy-scan.js") return true;
|
|
162
|
+
// pack-check.js is a release-safety enforcement script: it legitimately
|
|
163
|
+
// enumerates the forbidden-to-ship patterns (.env, governance dirs, key
|
|
164
|
+
// material), so it is treated as a policy file like the scanner itself.
|
|
165
|
+
if (relative === "scripts/pack-check.js") return true;
|
|
166
|
+
// The shared forbidden-in-pack rule module likewise names the generic
|
|
167
|
+
// forbidden patterns (.env, .claude, secrets, key material) as regex literals
|
|
168
|
+
// by design. It does not bake in any maintainer-private dir names.
|
|
169
|
+
if (relative === "scripts/lib/forbidden-in-pack.js") return true;
|
|
170
|
+
if (relative === "privacy-manifest.json") return true;
|
|
171
|
+
if (relative.startsWith(".aict/privacy/")) return true;
|
|
172
|
+
if (relative.startsWith("docs/") && (
|
|
173
|
+
name.includes("privacy") ||
|
|
174
|
+
name.includes("boundary") ||
|
|
175
|
+
name.includes("redaction") ||
|
|
176
|
+
name.includes("mapping") ||
|
|
177
|
+
name.includes("security") ||
|
|
178
|
+
name.includes("manifest")
|
|
179
|
+
)) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A Chinese run on a given line is "sanctioned" (part of the intentional
|
|
186
|
+
// bilingual surface) if the line itself is allowlisted, is a localized `zh:`
|
|
187
|
+
// string, or sits inside a bilingual block opened by a section marker within the
|
|
188
|
+
// configured context window. Everything else (stray, unmarked Chinese) is treated
|
|
189
|
+
// as a likely paste of private material and fails the scan.
|
|
190
|
+
function buildSanctionedLineSet(lines) {
|
|
191
|
+
const sanctioned = new Set();
|
|
192
|
+
let blockUntil = -1;
|
|
193
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
194
|
+
const line = lines[i];
|
|
195
|
+
if (sectionMarkerRegexes.some((re) => re.test(line))) {
|
|
196
|
+
blockUntil = i + (bilingual.contextWindow ?? 12);
|
|
197
|
+
}
|
|
198
|
+
if (i <= blockUntil) sanctioned.add(i);
|
|
199
|
+
if (lineAllowRegexes.some((re) => re.test(line))) sanctioned.add(i);
|
|
200
|
+
if (bilingual.zhKeyAllowed && /(^|[^\w])zh\s*:/.test(line)) sanctioned.add(i);
|
|
201
|
+
}
|
|
202
|
+
return sanctioned;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function scanChinese(content, relative, isPolicy, isBilingualFile = false) {
|
|
206
|
+
const errors = [];
|
|
207
|
+
// Policy/manifest/boundary files legitimately enumerate the forbidden Chinese
|
|
208
|
+
// terms and discuss the bilingual surface, so the whole Chinese pass is gated
|
|
209
|
+
// the same way as the English privateBoundaryPatterns. A real leak lands in a
|
|
210
|
+
// non-policy file (a doc, a template, a generated artifact), which is still
|
|
211
|
+
// scanned in full.
|
|
212
|
+
if (isPolicy) return errors;
|
|
213
|
+
// Hard denylist of known private Chinese terms — forbidden anywhere outside
|
|
214
|
+
// policy files, even on an otherwise-sanctioned bilingual line OR a whole-file
|
|
215
|
+
// sanctioned bilingual source. This stays ENFORCED so a private term like
|
|
216
|
+
// "真实客户" can never hide inside the i18n catalog either.
|
|
217
|
+
for (const term of denylist.chineseTerms ?? []) {
|
|
218
|
+
if (content.includes(term)) {
|
|
219
|
+
errors.push(`${relative}: contains denylisted Chinese private term "${term}"`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// A whole-file sanctioned bilingual source (the i18n message catalog) is allowed
|
|
224
|
+
// to be densely Chinese, so the unmarked-run HEURISTIC below would only produce
|
|
225
|
+
// noise. We skip just that heuristic for these files — the denylist above (and the
|
|
226
|
+
// token/key/path/email rules in scanFile) still apply.
|
|
227
|
+
if (isBilingualFile) return errors;
|
|
228
|
+
|
|
229
|
+
const lines = content.split(/\r?\n/);
|
|
230
|
+
const sanctioned = buildSanctionedLineSet(lines);
|
|
231
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
232
|
+
if (!cjkAny.test(lines[i])) continue;
|
|
233
|
+
if (sanctioned.has(i)) continue;
|
|
234
|
+
if (cjkRun.test(lines[i])) {
|
|
235
|
+
const sample = (lines[i].match(cjkRun) || [""])[0].slice(0, 24);
|
|
236
|
+
errors.push(`${relative}:${i + 1}: contains unsanctioned Chinese text (possible private leak): "${sample}…"`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return errors;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function scanFile(file, root) {
|
|
243
|
+
const content = readFileSync(file, "utf8");
|
|
244
|
+
const relative = path.relative(root, file);
|
|
245
|
+
const errors = [];
|
|
246
|
+
|
|
247
|
+
const alwaysForbidden = [
|
|
248
|
+
[/\/Users\/[^/\s]+(?:\/[^\s`'")]+)?/g, "local machine path"],
|
|
249
|
+
[/\/home\/[^/\s]+(?:\/[^\s`'")]+)?/g, "local machine path"],
|
|
250
|
+
[/[A-Za-z]:\\Users\\[^\\\s]+(?:\\[^\s`'")]+)?/g, "local machine path"],
|
|
251
|
+
// Home-directory path VARIANTS, parallel to the absolute /Users//home//C:\Users
|
|
252
|
+
// rules above. These are still GENERIC, anyone-applies markers (env-var home
|
|
253
|
+
// expansions and tilde home) — they name no specific private directory, so they
|
|
254
|
+
// do not bake any maintainer dir name into the shipped scanner.
|
|
255
|
+
// POSIX env-var home expansion (dollar-HOME, optionally brace-wrapped).
|
|
256
|
+
// Example forms are not written literally here so the scanner does not flag
|
|
257
|
+
// its own comment; see tests/contract.test.js for the concrete fixtures.
|
|
258
|
+
[/\$\{?HOME\}?\/[^\s`'")]+/g, "local machine path"],
|
|
259
|
+
// Windows env-var home expansion (percent-USERPROFILE-percent backslash ...).
|
|
260
|
+
// The percent signs are written as [%] char-classes so this regex literal does
|
|
261
|
+
// not match itself when the scanner scans its own source (same self-exemption
|
|
262
|
+
// technique as the escaped $HOME rule above); the matched input is identical.
|
|
263
|
+
[/[%]USERPROFILE[%]\\[^\s`'")]+/gi, "local machine path"],
|
|
264
|
+
// Tilde home subtree (tilde-slash named-dir slash more). Deliberately NARROW:
|
|
265
|
+
// it requires a NON-dot first segment plus a deeper level, so it flags a leaked
|
|
266
|
+
// real home path (a personal docs/desktop subtree) but NOT a bare tilde-slash, a
|
|
267
|
+
// single-segment tilde-slash-foo, or a standard dot tool/config dir (the npm log,
|
|
268
|
+
// config, or ssh dotfile dirs). Those dotfile dirs are generic-and-benign (the
|
|
269
|
+
// same stance as the Claude hooks dir); a maintainer's OWN private dir name is
|
|
270
|
+
// matched instead via the manifest/local denylist.paths loop, never hard-coded
|
|
271
|
+
// here. Concrete matchable examples live only in the test fixtures, never in
|
|
272
|
+
// this scanned source.
|
|
273
|
+
[/~\/(?!\.)[^/\s`'")]+\/[^\s`'")]+/g, "local machine path"],
|
|
274
|
+
[/\bgh[pousr]_[A-Za-z0-9_]{20,}/g, "GitHub token"],
|
|
275
|
+
[/\bgithub_pat_[A-Za-z0-9_]{30,}/g, "GitHub token"],
|
|
276
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{20,}/g, "Slack token"],
|
|
277
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]{24,}/gi, "Bearer token"],
|
|
278
|
+
[/\bapiKey\s*[:=]\s*["'][^"']{16,}["']/g, "apiKey"],
|
|
279
|
+
[/\b(?:api[_-]?key|token|secret)\s*[:=]\s*["'][^"']{12,}["']/gi, "API key / token / secret"],
|
|
280
|
+
[/(?<![A-Za-z0-9_])(?:\+?\d{1,3}[\s.-])?(?:\(?\d{3}\)?[\s.-])\d{3}[\s.-]\d{4}(?![A-Za-z0-9_])/g, "phone number"],
|
|
281
|
+
[/(?<![\d.\-])1[3-9]\d{9}(?![\d.\-])/g, "Chinese mobile number"],
|
|
282
|
+
[/sk-[A-Za-z0-9_-]{20,}/g, "OpenAI-style secret key"],
|
|
283
|
+
[/AKIA[0-9A-Z]{16}/g, "AWS access key"],
|
|
284
|
+
[/AIza[0-9A-Za-z_-]{20,}/g, "Google API key"],
|
|
285
|
+
[/-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----/g, "private key"],
|
|
286
|
+
[/sessionid\s*[:=]\s*[A-Za-z0-9_.-]{12,}/gi, "session id"],
|
|
287
|
+
[/password\s*[:=]\s*['"][^'"]{6,}['"]/gi, "literal password"],
|
|
288
|
+
[/(?<![A-Za-z0-9])[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?![A-Za-z0-9])/g, "UUID-style session id"]
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
for (const [regex, label] of alwaysForbidden) {
|
|
292
|
+
if (regex.test(content)) errors.push(`${relative}: contains ${label}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Email addresses: any address is a likely private leak EXCEPT one that EXACTLY
|
|
296
|
+
// matches the project's published public contact (manifest publicContacts). A
|
|
297
|
+
// different address — even at the same domain — still fails. This is what keeps a
|
|
298
|
+
// legitimate "contact: <public email>" footer in README/SECURITY/package.json from
|
|
299
|
+
// tripping the gate, without opening a domain or whole-category allowlist. Applied
|
|
300
|
+
// unconditionally (policy files included), exactly like the other always-forbidden
|
|
301
|
+
// patterns, so a non-public email cannot hide in a policy/boundary doc either.
|
|
302
|
+
const emailRegex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g;
|
|
303
|
+
const leakedEmails = (content.match(emailRegex) || []).filter((addr) => !publicContactEmails.has(addr.toLowerCase()));
|
|
304
|
+
if (leakedEmails.length > 0) {
|
|
305
|
+
errors.push(`${relative}: contains email address (${leakedEmails[0]})`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const policy = isPolicyFile(file, root);
|
|
309
|
+
if (!policy) {
|
|
310
|
+
// Manifest-driven literal path/term denylist (case-insensitive substring).
|
|
311
|
+
// Policy/boundary docs are allowed to *name* forbidden paths/terms, so this is
|
|
312
|
+
// gated the same way as privateBoundaryPatterns below.
|
|
313
|
+
const lowerContent = content.toLowerCase();
|
|
314
|
+
for (const needle of denylist.paths ?? []) {
|
|
315
|
+
if (lowerContent.includes(needle.toLowerCase())) errors.push(`${relative}: contains denylisted path "${needle}"`);
|
|
316
|
+
}
|
|
317
|
+
for (const needle of denylist.terms ?? []) {
|
|
318
|
+
if (lowerContent.includes(needle.toLowerCase())) errors.push(`${relative}: contains denylisted term "${needle}"`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Generic, anyone-applies boundary patterns. A maintainer's OWN private dir
|
|
322
|
+
// names (a personal governance folder, a private knowledge base) are NOT
|
|
323
|
+
// hard-coded here — that would ship those names in the public package. They
|
|
324
|
+
// are matched instead via the manifest/local `denylist.paths` loop above, so
|
|
325
|
+
// users configure their own in the gitignored privacy-scan.local.json.
|
|
326
|
+
// `.claude/hooks` stays as a generic marker (the standard Claude Code hooks
|
|
327
|
+
// dir that every Claude Code user has).
|
|
328
|
+
const privateBoundaryPatterns = [
|
|
329
|
+
[/\.claude\/hooks/gi, "Claude hooks dir"],
|
|
330
|
+
[/(^|[^\w.-])\.env(?:$|[^\w.-])/g, "environment file"],
|
|
331
|
+
[/\baccount[_ -]?id\s*[:=]\s*[A-Za-z0-9_.-]{8,}|\bacct_(?:live|prod|private)[A-Za-z0-9_.-]*/gi, "account identifier"],
|
|
332
|
+
[/\breal customer\b|\bcustomer name\b|\bclient name\b/gi, "real customer"],
|
|
333
|
+
[/\breal project\b|\bprivate project\b|\bsecret project\b/gi, "real project"],
|
|
334
|
+
[/\breal conversation\b|\braw private conversation\b|\bprivate chat transcript\b/gi, "real conversation"],
|
|
335
|
+
[/\bprivate hook\b/gi, "private hook"],
|
|
336
|
+
[/\bprivate route\b|\binternal route\b|\bprivate routing rules\b/gi, "private route"],
|
|
337
|
+
[/\b(?:internal\s+)?threshold\s*[:=]\s*[0-9.]+|\bcalibration thresholds?\b/gi, "internal threshold"],
|
|
338
|
+
[/\b(?:internal\s+)?(?:scoring\s+)?weight\s*[:=]\s*[0-9.]+|\bjudgment weights?\b/gi, "internal weight"],
|
|
339
|
+
[/\binternal session(?: id)?\s*[:=]\s*[A-Za-z0-9_.-]{12,}|\bsess_[A-Za-z0-9_.-]{12,}/gi, "internal session id"]
|
|
340
|
+
];
|
|
341
|
+
for (const [regex, label] of privateBoundaryPatterns) {
|
|
342
|
+
if (regex.test(content)) errors.push(`${relative}: contains ${label}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const relativePosix = relative.split(path.sep).join("/");
|
|
347
|
+
const isBilingualFile = sanctionedBilingualFiles.has(relativePosix);
|
|
348
|
+
errors.push(...scanChinese(content, relative, policy, isBilingualFile));
|
|
349
|
+
|
|
350
|
+
return errors;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// jsonl is included so the P1 run-layer ledgers (state/*.jsonl) are scanned for
|
|
354
|
+
// leaked emails / tokens / local paths just like every other shipped text file.
|
|
355
|
+
// Omitting it would let a real secret pasted into a ledger ship while the scan
|
|
356
|
+
// stays silently green.
|
|
357
|
+
const SCANNED_EXT = /\.(md|mdc|json|jsonl|js|txt|yml|yaml)$/;
|
|
358
|
+
|
|
359
|
+
function isScannable(file) {
|
|
360
|
+
try {
|
|
361
|
+
return statSync(file).isFile() && (SCANNED_EXT.test(file) || path.basename(file) === ".clinerules");
|
|
362
|
+
} catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function scanDirectory(root, label) {
|
|
368
|
+
const files = walk(root).filter(isScannable);
|
|
369
|
+
const errors = files.flatMap((file) => scanFile(file, root));
|
|
370
|
+
return { label, root, count: files.length, errors };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Extra (side-effecting) scan surfaces. These run by default to harden release
|
|
375
|
+
// safety, but are skipped with --no-extras / when a target --workspace is given,
|
|
376
|
+
// so the core read-only scan still works in a strict sandbox.
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
function scanAdaptersInstallOutput() {
|
|
379
|
+
// Actually run `adapters install` into a throwaway dir and scan what src/adapters.js
|
|
380
|
+
// would write into a user's external repo.
|
|
381
|
+
let tmp;
|
|
382
|
+
try {
|
|
383
|
+
tmp = mkdtempSync(path.join(tmpdir(), "aicos-privacy-adapters-"));
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return { label: "adapters-install output", root: "(skipped)", count: 0, errors: [], skipped: `cannot create temp dir (${error.code || error.message}); pass --no-extras in a read-only sandbox` };
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
// --tool all so every adapter entrypoint's rendered content is scanned (the
|
|
389
|
+
// default --tool auto would detect no tool in this empty temp dir and write
|
|
390
|
+
// nothing, leaving this privacy surface empty).
|
|
391
|
+
execFileSync(process.execPath, [path.join(repoRoot, "bin", "ai-collab.js"), "adapters", "install", "--target", tmp, "--force", "--tool", "all"], {
|
|
392
|
+
cwd: repoRoot,
|
|
393
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
394
|
+
});
|
|
395
|
+
const result = scanDirectory(tmp, "adapters-install output");
|
|
396
|
+
return result;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
return { label: "adapters-install output", root: tmp, count: 0, errors: [`adapters install failed: ${error.message}`] };
|
|
399
|
+
} finally {
|
|
400
|
+
if (tmp) {
|
|
401
|
+
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function scanPackFileList() {
|
|
407
|
+
// npm pack --dry-run --json lists exactly what would ship. Make sure no private
|
|
408
|
+
// file (e.g. .env, a backup, a private config) sneaks into the tarball. The
|
|
409
|
+
// forbidden-file rules live in scripts/lib/forbidden-in-pack.js so this scanner
|
|
410
|
+
// and scripts/pack-check.js enforce one shared list (no drift).
|
|
411
|
+
try {
|
|
412
|
+
const output = execFileSync("npm", ["pack", "--dry-run", "--json"], {
|
|
413
|
+
cwd: repoRoot,
|
|
414
|
+
encoding: "utf8",
|
|
415
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
416
|
+
});
|
|
417
|
+
const [pack] = JSON.parse(output);
|
|
418
|
+
const files = (pack.files ?? []).map((file) => file.path);
|
|
419
|
+
// Also block any user-configured private dirs (from manifest/local denylist
|
|
420
|
+
// paths) from the tarball, without naming them in the shared shipped module.
|
|
421
|
+
const errors = findForbiddenPackFiles(files, configuredPrivateDirs).map(
|
|
422
|
+
({ label, file }) => `npm pack would ship ${label}: ${file}`
|
|
423
|
+
);
|
|
424
|
+
return { label: "npm pack file list", root: "(npm pack --dry-run)", count: files.length, errors };
|
|
425
|
+
} catch (error) {
|
|
426
|
+
return { label: "npm pack file list", root: "(npm pack --dry-run)", count: 0, errors: [], skipped: `npm pack unavailable (${error.code || "error"}); set npm_config_cache to a writable dir or pass --no-extras` };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const args = parseArgs(process.argv.slice(2));
|
|
431
|
+
|
|
432
|
+
// --strict means "do not skip any scan surface". --no-extras explicitly skips the
|
|
433
|
+
// side-effecting surfaces (adapters-install output + npm pack file list). Asking
|
|
434
|
+
// for both at once is self-contradictory, so reject it instead of silently letting
|
|
435
|
+
// strict pass while real scan surfaces were dropped. (Previously --strict --no-extras
|
|
436
|
+
// exited 0 because the skipped extras were never recorded in `skipped`.)
|
|
437
|
+
if (args.strict && args.noExtras) {
|
|
438
|
+
console.error(
|
|
439
|
+
"Privacy scan (strict): --strict and --no-extras are contradictory. " +
|
|
440
|
+
"--strict requires every scan surface to run, but --no-extras skips the " +
|
|
441
|
+
"side-effecting surfaces (adapters-install output + npm pack file list). " +
|
|
442
|
+
"Run strict in a writable environment without --no-extras, or drop --strict."
|
|
443
|
+
);
|
|
444
|
+
process.exit(1);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const targetWorkspace = args.workspace ? path.resolve(args.workspace) : repoRoot;
|
|
448
|
+
const scanningRepo = !args.workspace;
|
|
449
|
+
|
|
450
|
+
const surfaces = [];
|
|
451
|
+
// Source tree + generated .aict are both under repoRoot, so the single walk of
|
|
452
|
+
// repoRoot covers "source tree (src/ + scripts/)" and "generated .aict". When a
|
|
453
|
+
// --workspace is given we scan exactly that tree.
|
|
454
|
+
surfaces.push(scanDirectory(targetWorkspace, scanningRepo ? "source tree + committed .aict" : "workspace"));
|
|
455
|
+
|
|
456
|
+
// Side-effecting surfaces only when scanning the repo and not asked to skip them.
|
|
457
|
+
const runExtras = scanningRepo && !args.noExtras;
|
|
458
|
+
if (runExtras) {
|
|
459
|
+
surfaces.push(scanAdaptersInstallOutput());
|
|
460
|
+
surfaces.push(scanPackFileList());
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const allErrors = surfaces.flatMap((surface) => surface.errors);
|
|
464
|
+
const skipped = surfaces.filter((surface) => surface.skipped);
|
|
465
|
+
|
|
466
|
+
if (allErrors.length > 0) {
|
|
467
|
+
console.error(`Privacy scan failed:\n${allErrors.map((error) => `- ${error}`).join("\n")}`);
|
|
468
|
+
if (skipped.length > 0) {
|
|
469
|
+
console.error(`\nSkipped surfaces:\n${skipped.map((s) => `- ${s.label}: ${s.skipped}`).join("\n")}`);
|
|
470
|
+
}
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (args.strict && skipped.length > 0) {
|
|
475
|
+
console.error(`Privacy scan (strict): required surfaces were skipped:\n${skipped.map((s) => `- ${s.label}: ${s.skipped}`).join("\n")}`);
|
|
476
|
+
process.exit(1);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const summary = surfaces
|
|
480
|
+
.map((surface) => ` - ${surface.label}: ${surface.skipped ? `skipped (${surface.skipped})` : `${surface.count} entr${surface.count === 1 ? "y" : "ies"} clean`}`)
|
|
481
|
+
.join("\n");
|
|
482
|
+
|
|
483
|
+
console.log(`Privacy scan passed.
|
|
484
|
+
Root: ${targetWorkspace}
|
|
485
|
+
Surfaces:
|
|
486
|
+
${summary}
|
|
487
|
+
`);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdtempSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createWorkspace } from "../src/workspace.js";
|
|
6
|
+
import { validateWorkspace } from "../src/validate.js";
|
|
7
|
+
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const args = {};
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
if (argv[index] === "--workspace") {
|
|
12
|
+
args.workspace = argv[index + 1];
|
|
13
|
+
index += 1;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return args;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fail(errors) {
|
|
20
|
+
console.error(`Contract check failed:\n${errors.map((error) => `- ${error}`).join("\n")}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function checkRoot(repoRoot) {
|
|
25
|
+
const errors = [];
|
|
26
|
+
for (const file of ["README.md", "PRODUCT_CONTRACT.md", "package.json", "privacy-manifest.json"]) {
|
|
27
|
+
if (!existsSync(path.join(repoRoot, file))) errors.push(`missing root file ${file}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const file of [
|
|
31
|
+
"00-start-here.md",
|
|
32
|
+
"01-ai-collaboration-os.md",
|
|
33
|
+
"02-six-layer-architecture.md",
|
|
34
|
+
"03-role-system.md",
|
|
35
|
+
"04-core-mechanisms.md",
|
|
36
|
+
"05-failure-patterns.md",
|
|
37
|
+
"06-how-to-adapt-to-your-workflow.md"
|
|
38
|
+
]) {
|
|
39
|
+
if (!existsSync(path.join(repoRoot, "docs", "open-system", file))) {
|
|
40
|
+
errors.push(`missing open-system doc ${file}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!existsSync(path.join(repoRoot, "docs", "PUBLIC_MAPPING.md"))) {
|
|
44
|
+
errors.push("missing docs/PUBLIC_MAPPING.md");
|
|
45
|
+
}
|
|
46
|
+
if (!existsSync(path.join(repoRoot, "docs", "WHY_THIS_EXISTS.md"))) {
|
|
47
|
+
errors.push("missing docs/WHY_THIS_EXISTS.md");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (existsSync(path.join(repoRoot, "README.md"))) {
|
|
51
|
+
const readme = readFileSync(path.join(repoRoot, "README.md"), "utf8");
|
|
52
|
+
const firstScreen = readme.slice(0, 1800);
|
|
53
|
+
if (!/open-source personal AI collaboration workspace/i.test(firstScreen)) {
|
|
54
|
+
errors.push("README first screen must position the open-source personal AI collaboration workspace");
|
|
55
|
+
}
|
|
56
|
+
if (!/START_HERE\.md/.test(firstScreen)) {
|
|
57
|
+
errors.push("README first screen must point to START_HERE.md");
|
|
58
|
+
}
|
|
59
|
+
if (/doctor|diagnos/i.test(firstScreen)) {
|
|
60
|
+
errors.push("README first screen must not lead with diagnosis or doctor framing");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (existsSync(path.join(repoRoot, "START_HERE.md"))) {
|
|
65
|
+
const startHere = readFileSync(path.join(repoRoot, "START_HERE.md"), "utf8");
|
|
66
|
+
for (const phrase of ["10 minutes", "30 minutes", "60 minutes"]) {
|
|
67
|
+
if (!new RegExp(phrase, "i").test(startHere)) {
|
|
68
|
+
errors.push(`root START_HERE.md missing ${phrase}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return errors;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function listFiles(root, base = root, files = []) {
|
|
77
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
78
|
+
const fullPath = path.join(root, entry.name);
|
|
79
|
+
if (entry.isDirectory()) listFiles(fullPath, base, files);
|
|
80
|
+
else if (entry.isFile()) files.push(path.relative(base, fullPath).split(path.sep).join("/"));
|
|
81
|
+
}
|
|
82
|
+
return files.sort();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function compareGeneratedWorkspace(generatedWorkspace, committedWorkspace) {
|
|
86
|
+
const errors = [];
|
|
87
|
+
if (!existsSync(committedWorkspace) || !statSync(committedWorkspace).isDirectory()) {
|
|
88
|
+
return [`missing committed workspace ${committedWorkspace}`];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const generatedFiles = listFiles(generatedWorkspace);
|
|
92
|
+
const committedFiles = listFiles(committedWorkspace);
|
|
93
|
+
const generatedSet = new Set(generatedFiles);
|
|
94
|
+
const committedSet = new Set(committedFiles);
|
|
95
|
+
|
|
96
|
+
for (const file of generatedFiles) {
|
|
97
|
+
if (!committedSet.has(file)) errors.push(`committed .aict missing generated file ${file}`);
|
|
98
|
+
}
|
|
99
|
+
for (const file of committedFiles) {
|
|
100
|
+
if (!generatedSet.has(file)) errors.push(`committed .aict has non-generated file ${file}`);
|
|
101
|
+
}
|
|
102
|
+
for (const file of generatedFiles.filter((item) => committedSet.has(item))) {
|
|
103
|
+
const generated = readFileSync(path.join(generatedWorkspace, file), "utf8");
|
|
104
|
+
const committed = readFileSync(path.join(committedWorkspace, file), "utf8");
|
|
105
|
+
if (generated !== committed) errors.push(`committed .aict differs from generator for ${file}`);
|
|
106
|
+
}
|
|
107
|
+
return errors;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Generate a fresh workspace into a writable temp dir so we can diff it against
|
|
111
|
+
// the committed .aict. In a strict read-only sandbox the temp dir is not writable;
|
|
112
|
+
// rather than crash with a cryptic mkdtemp EPERM/ENOENT we fall back to validating
|
|
113
|
+
// the committed .aict in place (or an explicit --workspace) and tell the caller how
|
|
114
|
+
// to get the full generate-and-compare check back.
|
|
115
|
+
function makeGeneratedWorkspace() {
|
|
116
|
+
try {
|
|
117
|
+
return createWorkspace(mkdtempSync(path.join(tmpdir(), "aicos-contract-")), { force: true }).workspaceRoot;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (["EPERM", "EACCES", "EROFS", "ENOENT"].includes(error.code)) {
|
|
120
|
+
console.warn(
|
|
121
|
+
`Contract check: cannot create a temp workspace (${error.message}).\n` +
|
|
122
|
+
`Falling back to validating the committed .aict in place — the generate-and-compare check is skipped.\n` +
|
|
123
|
+
`To run the full check in a read-only sandbox, generate a workspace in a writable dir first and pass it:\n` +
|
|
124
|
+
` node bin/ai-collab.js init --target <writable-dir> --force\n` +
|
|
125
|
+
` node scripts/validate-contract.js --workspace <writable-dir>/.aict`
|
|
126
|
+
);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const repoRoot = path.resolve(new URL("..", import.meta.url).pathname);
|
|
134
|
+
const args = parseArgs(process.argv.slice(2));
|
|
135
|
+
const generatedWorkspace = args.workspace ? null : makeGeneratedWorkspace();
|
|
136
|
+
const workspaces = args.workspace
|
|
137
|
+
? [path.resolve(args.workspace)]
|
|
138
|
+
: [
|
|
139
|
+
generatedWorkspace,
|
|
140
|
+
path.join(repoRoot, ".aict")
|
|
141
|
+
].filter((workspace) => workspace && existsSync(workspace));
|
|
142
|
+
|
|
143
|
+
const rootErrors = checkRoot(repoRoot);
|
|
144
|
+
const workspaceResults = workspaces.map((workspace) => ({ workspace, result: validateWorkspace(workspace) }));
|
|
145
|
+
const errors = [
|
|
146
|
+
...rootErrors,
|
|
147
|
+
...(generatedWorkspace ? compareGeneratedWorkspace(generatedWorkspace, path.join(repoRoot, ".aict")) : []),
|
|
148
|
+
...workspaceResults.flatMap(({ workspace, result }) =>
|
|
149
|
+
result.errors.map((error) => `${workspace}: ${error}`)
|
|
150
|
+
)
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
if (errors.length > 0) {
|
|
154
|
+
fail(errors);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
console.log(`Contract check passed.
|
|
158
|
+
Workspaces: ${workspaces.join(", ")}
|
|
159
|
+
Checks: ${workspaceResults.reduce((total, item) => total + item.result.checks, 0) + 4}
|
|
160
|
+
`);
|